feat(auth): Enhance OAuth callback for robust Docker support (#3532)

Co-authored-by: Scott Densmore <scottdensmore@mac.com>
This commit is contained in:
Yongsheng Xu 2025-07-18 09:55:26 +08:00 committed by GitHub
parent 2f5eecfc49
commit 91c69731c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 40 additions and 4 deletions

View File

@ -105,7 +105,7 @@ describe('oauth2', () => {
let capturedPort = 0; let capturedPort = 0;
const mockHttpServer = { const mockHttpServer = {
listen: vi.fn((port: number, callback?: () => void) => { listen: vi.fn((port: number, _host: string, callback?: () => void) => {
capturedPort = port; capturedPort = port;
if (callback) { if (callback) {
callback(); callback();

View File

@ -139,13 +139,33 @@ export async function getOauthClient(
} else { } else {
const webLogin = await authWithWeb(client); const webLogin = await authWithWeb(client);
// This does basically nothing, as it isn't show to the user.
console.log( console.log(
`\n\nCode Assist login required.\n` + `\n\nCode Assist login required.\n` +
`Attempting to open authentication page in your browser.\n` + `Attempting to open authentication page in your browser.\n` +
`Otherwise navigate to:\n\n${webLogin.authUrl}\n\n`, `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n`,
); );
await open(webLogin.authUrl); try {
// Attempt to open the authentication URL in the default browser.
// We do not use the `wait` option here because the main script's execution
// is already paused by `loginCompletePromise`, which awaits the server callback.
const childProcess = await open(webLogin.authUrl);
// IMPORTANT: Attach an error handler to the returned child process.
// Without this, if `open` fails to spawn a process (e.g., `xdg-open` is not found
// in a minimal Docker container), it will emit an unhandled 'error' event,
// causing the entire Node.js process to crash.
childProcess.on('error', (_) => {
console.error(
'Failed to open browser automatically. Please open the URL manually:',
);
console.error(webLogin.authUrl);
});
} catch (err) {
console.error(
'An unexpected error occurred while trying to open the browser:',
err,
);
}
console.log('Waiting for authentication...'); console.log('Waiting for authentication...');
await webLogin.loginCompletePromise; await webLogin.loginCompletePromise;
@ -202,6 +222,12 @@ async function authWithUserCode(client: OAuth2Client): Promise<boolean> {
async function authWithWeb(client: OAuth2Client): Promise<OauthWebLogin> { async function authWithWeb(client: OAuth2Client): Promise<OauthWebLogin> {
const port = await getAvailablePort(); const port = await getAvailablePort();
// The hostname used for the HTTP server binding (e.g., '0.0.0.0' in Docker).
const host = process.env.OAUTH_CALLBACK_HOST || 'localhost';
// The `redirectUri` sent to Google's authorization server MUST use a loopback IP literal
// (i.e., 'localhost' or '127.0.0.1'). This is a strict security policy for credentials of
// type 'Desktop app' or 'Web application' (when using loopback flow) to mitigate
// authorization code interception attacks.
const redirectUri = `http://localhost:${port}/oauth2callback`; const redirectUri = `http://localhost:${port}/oauth2callback`;
const state = crypto.randomBytes(32).toString('hex'); const state = crypto.randomBytes(32).toString('hex');
const authUrl = client.generateAuthUrl({ const authUrl = client.generateAuthUrl({
@ -259,7 +285,7 @@ async function authWithWeb(client: OAuth2Client): Promise<OauthWebLogin> {
server.close(); server.close();
} }
}); });
server.listen(port); server.listen(port, host);
}); });
return { return {
@ -272,6 +298,16 @@ export function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let port = 0; let port = 0;
try { try {
const portStr = process.env.OAUTH_CALLBACK_PORT;
if (portStr) {
port = parseInt(portStr, 10);
if (isNaN(port) || port <= 0 || port > 65535) {
return reject(
new Error(`Invalid value for OAUTH_CALLBACK_PORT: "${portStr}"`),
);
}
return resolve(port);
}
const server = net.createServer(); const server = net.createServer();
server.listen(0, () => { server.listen(0, () => {
const address = server.address()! as net.AddressInfo; const address = server.address()! as net.AddressInfo;