fix: make OAuth callback port configurable for Windows compatibility (#176)

- Add OAUTH_CALLBACK_PORT environment variable (default: 51121)
- Implement automatic port fallback (51122-51126) on EACCES/EADDRINUSE
- Add Windows-specific troubleshooting in error messages and README
- Document configuration in config.example.json

Closes #176
This commit is contained in:
quocthai0404
2026-01-24 14:28:31 +07:00
parent 71b9b001fd
commit 54fc1da829
4 changed files with 156 additions and 15 deletions

View File

@@ -137,22 +137,50 @@ export function extractCodeFromInput(input) {
return { code: trimmed, state: null };
}
/**
* Attempt to bind server to a specific port
* @param {http.Server} server - HTTP server instance
* @param {number} port - Port to bind to
* @returns {Promise<number>} Resolves with port on success, rejects on error
*/
function tryBindPort(server, port) {
return new Promise((resolve, reject) => {
const onError = (err) => {
server.removeListener('listening', onSuccess);
reject(err);
};
const onSuccess = () => {
server.removeListener('error', onError);
resolve(port);
};
server.once('error', onError);
server.once('listening', onSuccess);
server.listen(port);
});
}
/**
* Start a local server to receive the OAuth callback
* Implements automatic port fallback for Windows compatibility (issue #176)
* Returns an object with a promise and an abort function
*
* @param {string} expectedState - Expected state parameter for CSRF protection
* @param {number} timeoutMs - Timeout in milliseconds (default 120000)
* @returns {{promise: Promise<string>, abort: Function}} Object with promise and abort function
* @returns {{promise: Promise<string>, abort: Function, getPort: Function}} Object with promise, abort, and getPort functions
*/
export function startCallbackServer(expectedState, timeoutMs = 120000) {
let server = null;
let timeoutId = null;
let isAborted = false;
let actualPort = OAUTH_CONFIG.callbackPort;
const promise = new Promise(async (resolve, reject) => {
// Build list of ports to try: primary + fallbacks
const portsToTry = [OAUTH_CONFIG.callbackPort, ...(OAUTH_CONFIG.callbackFallbackPorts || [])];
const errors = [];
const promise = new Promise((resolve, reject) => {
server = http.createServer((req, res) => {
const url = new URL(req.url, `http://localhost:${OAUTH_CONFIG.callbackPort}`);
const url = new URL(req.url, `http://localhost:${actualPort}`);
if (url.pathname !== '/oauth-callback') {
res.writeHead(404);
@@ -232,17 +260,60 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
resolve(code);
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
reject(new Error(`Port ${OAUTH_CONFIG.callbackPort} is already in use. Close any other OAuth flows and try again.`));
} else {
reject(err);
}
});
// Try ports with fallback logic (issue #176 - Windows EACCES fix)
let boundSuccessfully = false;
for (const port of portsToTry) {
try {
await tryBindPort(server, port);
actualPort = port;
boundSuccessfully = true;
server.listen(OAUTH_CONFIG.callbackPort, () => {
logger.info(`[OAuth] Callback server listening on port ${OAUTH_CONFIG.callbackPort}`);
});
if (port !== OAUTH_CONFIG.callbackPort) {
logger.warn(`[OAuth] Primary port ${OAUTH_CONFIG.callbackPort} unavailable, using fallback port ${port}`);
} else {
logger.info(`[OAuth] Callback server listening on port ${port}`);
}
break;
} catch (err) {
const errMsg = err.code === 'EACCES'
? `Permission denied on port ${port}`
: err.code === 'EADDRINUSE'
? `Port ${port} already in use`
: `Failed to bind port ${port}: ${err.message}`;
errors.push(errMsg);
logger.warn(`[OAuth] ${errMsg}`);
}
}
if (!boundSuccessfully) {
// All ports failed - provide helpful error message
const isWindows = process.platform === 'win32';
let errorMsg = `Failed to start OAuth callback server.\nTried ports: ${portsToTry.join(', ')}\n\nErrors:\n${errors.join('\n')}`;
if (isWindows) {
errorMsg += `\n
================== WINDOWS TROUBLESHOOTING ==================
The default port range may be reserved by Hyper-V/WSL2/Docker.
Option 1: Use a custom port
Set OAUTH_CALLBACK_PORT=3456 in your environment or .env file
Option 2: Reset Windows NAT (run as Administrator)
net stop winnat && net start winnat
Option 3: Check reserved port ranges
netsh interface ipv4 show excludedportrange protocol=tcp
Option 4: Exclude port from reservation (run as Administrator)
netsh int ipv4 add excludedportrange protocol=tcp startport=51121 numberofports=1
==============================================================`;
} else {
errorMsg += `\n\nTry setting a custom port: OAUTH_CALLBACK_PORT=3456`;
}
reject(new Error(errorMsg));
return;
}
// Timeout after specified duration
timeoutId = setTimeout(() => {
@@ -266,7 +337,10 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
}
};
return { promise, abort };
// Get actual port (useful when fallback is used)
const getPort = () => actualPort;
return { promise, abort, getPort };
}
/**

View File

@@ -171,13 +171,19 @@ export function isThinkingModel(modelName) {
}
// Google OAuth configuration (from opencode-antigravity-auth)
// OAuth callback port - configurable via environment variable for Windows compatibility (issue #176)
// Windows may reserve ports in range 49152-65535 for Hyper-V/WSL2/Docker, causing EACCES errors
const OAUTH_CALLBACK_PORT = parseInt(process.env.OAUTH_CALLBACK_PORT || '51121', 10);
const OAUTH_CALLBACK_FALLBACK_PORTS = [51122, 51123, 51124, 51125, 51126];
export const OAUTH_CONFIG = {
clientId: '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com',
clientSecret: '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',
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
userInfoUrl: 'https://www.googleapis.com/oauth2/v1/userinfo',
callbackPort: 51121,
callbackPort: OAUTH_CALLBACK_PORT,
callbackFallbackPorts: OAUTH_CALLBACK_FALLBACK_PORTS,
scopes: [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',