/ services / mcp / oauthPort.ts
oauthPort.ts
 1  /**
 2   * OAuth redirect port helpers — extracted from auth.ts to break the
 3   * auth.ts ↔ xaaIdpLogin.ts circular dependency.
 4   */
 5  import { createServer } from 'http'
 6  import { getPlatform } from '../../utils/platform.js'
 7  
 8  // Windows dynamic port range 49152-65535 is reserved
 9  const REDIRECT_PORT_RANGE =
10    getPlatform() === 'windows'
11      ? { min: 39152, max: 49151 }
12      : { min: 49152, max: 65535 }
13  const REDIRECT_PORT_FALLBACK = 3118
14  
15  /**
16   * Builds a redirect URI on localhost with the given port and a fixed `/callback` path.
17   *
18   * RFC 8252 Section 7.3 (OAuth for Native Apps): loopback redirect URIs match any
19   * port as long as the path matches.
20   */
21  export function buildRedirectUri(
22    port: number = REDIRECT_PORT_FALLBACK,
23  ): string {
24    return `http://localhost:${port}/callback`
25  }
26  
27  function getMcpOAuthCallbackPort(): number | undefined {
28    const port = parseInt(process.env.MCP_OAUTH_CALLBACK_PORT || '', 10)
29    return port > 0 ? port : undefined
30  }
31  
32  /**
33   * Finds an available port in the specified range for OAuth redirect
34   * Uses random selection for better security
35   */
36  export async function findAvailablePort(): Promise<number> {
37    // First, try the configured port if specified
38    const configuredPort = getMcpOAuthCallbackPort()
39    if (configuredPort) {
40      return configuredPort
41    }
42  
43    const { min, max } = REDIRECT_PORT_RANGE
44    const range = max - min + 1
45    const maxAttempts = Math.min(range, 100) // Don't try forever
46  
47    for (let attempt = 0; attempt < maxAttempts; attempt++) {
48      const port = min + Math.floor(Math.random() * range)
49  
50      try {
51        await new Promise<void>((resolve, reject) => {
52          const testServer = createServer()
53          testServer.once('error', reject)
54          testServer.listen(port, () => {
55            testServer.close(() => resolve())
56          })
57        })
58        return port
59      } catch {
60        // Port in use, try another random port
61        continue
62      }
63    }
64  
65    // If random selection failed, try the fallback port
66    try {
67      await new Promise<void>((resolve, reject) => {
68        const testServer = createServer()
69        testServer.once('error', reject)
70        testServer.listen(REDIRECT_PORT_FALLBACK, () => {
71          testServer.close(() => resolve())
72        })
73      })
74      return REDIRECT_PORT_FALLBACK
75    } catch {
76      throw new Error(`No available ports for OAuth redirect`)
77    }
78  }