From 9f0e16bd8259b736c1c1a2c301a6259077203997 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Thu, 11 Dec 2025 16:52:21 -0800 Subject: [PATCH 1/3] fix: use console.error for auth messages to support headless environments --- workspace-server/src/auth/AuthManager.ts | 2 +- workspace-server/src/utils/open-wrapper.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/workspace-server/src/auth/AuthManager.ts b/workspace-server/src/auth/AuthManager.ts index acd7f76..2f23c56 100644 --- a/workspace-server/src/auth/AuthManager.ts +++ b/workspace-server/src/auth/AuthManager.ts @@ -156,7 +156,7 @@ export class AuthManager { const webLogin = await this.authWithWeb(oAuth2Client); await open(webLogin.authUrl); - console.log('Waiting for authentication...'); + console.error('Waiting for authentication...'); // Add timeout to prevent infinite waiting when browser tab gets stuck const authTimeout = 5 * 60 * 1000; // 5 minutes timeout diff --git a/workspace-server/src/utils/open-wrapper.ts b/workspace-server/src/utils/open-wrapper.ts index 3575e86..26c4d5d 100644 --- a/workspace-server/src/utils/open-wrapper.ts +++ b/workspace-server/src/utils/open-wrapper.ts @@ -33,7 +33,7 @@ const createMockChildProcess = () => ({ const openWrapper = async (url: string): Promise => { // Check if we should launch the browser if (!shouldLaunchBrowser()) { - console.log(`Browser launch not supported. Please open this URL in your browser: ${url}`); + console.error(`Browser launch not supported. Please open this URL in your browser: ${url}`); return createMockChildProcess(); } @@ -42,7 +42,7 @@ const openWrapper = async (url: string): Promise => { await openBrowserSecurely(url); return createMockChildProcess(); } catch { - console.log(`Failed to open browser. Please open this URL in your browser: ${url}`); + console.error(`Failed to open browser. Please open this URL in your browser: ${url}`); return createMockChildProcess(); } }; From 17003937b6b874e0be4acce54f2fb053a329ebb9 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Thu, 11 Dec 2025 16:57:33 -0800 Subject: [PATCH 2/3] feat: implement manual auth flow for headless environments --- cloud_function/index.js | 14 ++-- workspace-server/src/auth/AuthManager.ts | 92 +++++++++++++++++++----- 2 files changed, 80 insertions(+), 26 deletions(-) diff --git a/cloud_function/index.js b/cloud_function/index.js index 75d8787..cef7371 100644 --- a/cloud_function/index.js +++ b/cloud_function/index.js @@ -182,17 +182,13 @@ async function handleCallback(req, res) { Copied!
-

Keychain Storage Instructions:

+

Instructions:

    -
  1. Open your OS Keychain/Credential Manager.
  2. -
  3. Create a new secure entry (e.g., a "Generic Password" on macOS, a "Windows Credential", or similar on Linux).
  4. -
  5. Set the **Service** (or equivalent field) to: ${KEYCHAIN_SERVICE_NAME}
  6. -
  7. Set the **Account** (or username field) to: ${KEYCHAIN_ACCOUNT_NAME}
  8. -
  9. Paste the copied JSON into the **Password/Secret** field.
  10. -
  11. Save the entry.
  12. +
  13. Click the "Copy JSON" button above.
  14. +
  15. Paste the copied JSON into your terminal application where the extension is running.
  16. +
  17. The extension will automatically save these credentials securely.
-

Your local MCP server will now be able to find and use these credentials automatically.

-

(If keychain is unavailable, the server falls back to an encrypted file, but keychain is recommended.)

+

(Alternatively, you can manually save this JSON to your OS Keychain with Service: ${KEYCHAIN_SERVICE_NAME} and Account: ${KEYCHAIN_ACCOUNT_NAME})

diff --git a/workspace-server/src/auth/AuthManager.ts b/workspace-server/src/auth/AuthManager.ts index 2f23c56..aef96da 100644 --- a/workspace-server/src/auth/AuthManager.ts +++ b/workspace-server/src/auth/AuthManager.ts @@ -9,6 +9,7 @@ import crypto from 'node:crypto'; import * as http from 'node:http'; import * as net from 'node:net'; import * as url from 'node:url'; +import * as readline from 'node:readline'; import { logToFile } from '../utils/logger'; import open from '../utils/open-wrapper'; import { shouldLaunchBrowser } from '../utils/secure-browser-launcher'; @@ -67,6 +68,59 @@ export class AuthManager { return false; } + private async authManual(client: Auth.OAuth2Client): Promise { + logToFile(`Requesting manual authentication with scopes: ${this.scopes.join(', ')}`); + + // SECURITY: Generate a random token for CSRF protection. + const csrfToken = crypto.randomBytes(32).toString('hex'); + + // The state now contains a JSON payload indicating the flow mode and CSRF token. + const statePayload = { + manual: true, + csrf: csrfToken, + }; + const state = Buffer.from(JSON.stringify(statePayload)).toString('base64'); + + // The redirect URI for Google's auth server is the cloud function + const cloudFunctionRedirectUri = 'https://google-workspace-extension.geminicli.com'; + + const authUrl = client.generateAuthUrl({ + redirect_uri: cloudFunctionRedirectUri, // Tell Google to go to the cloud function + access_type: 'offline', + scope: this.scopes, + state: state, // Pass our JSON payload in the state + prompt: 'consent', // Make sure we get a refresh token + }); + + console.error('Browser launch not supported or disabled.'); + console.error('Please open the following URL in your browser to authenticate:'); + console.error('\n' + authUrl + '\n'); + console.error('After authenticating, copy the JSON credential block and paste it here.'); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, // Use stderr so prompts don't interfere with stdout + }); + + return new Promise((resolve, reject) => { + rl.question('Paste credentials JSON here: ', (answer) => { + rl.close(); + try { + const tokens = JSON.parse(answer.trim()); + if (tokens.access_token) { + client.setCredentials(tokens); + logToFile('Manual authentication successful'); + resolve(); + } else { + reject(new Error('Invalid credentials JSON: missing access_token')); + } + } catch (e) { + reject(new Error(`Failed to parse credentials JSON: ${e}`)); + } + }); + }); + } + public async getAuthenticatedClient(): Promise { logToFile('getAuthenticatedClient called'); @@ -154,23 +208,27 @@ export class AuthManager { } } - const webLogin = await this.authWithWeb(oAuth2Client); - await open(webLogin.authUrl); - console.error('Waiting for authentication...'); - - // Add timeout to prevent infinite waiting when browser tab gets stuck - const authTimeout = 5 * 60 * 1000; // 5 minutes timeout - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject( - new Error( - 'Authentication timed out after 5 minutes. The browser tab may have gotten stuck in a loading state. ' + - 'Please try again.', - ), - ); - }, authTimeout); - }); - await Promise.race([webLogin.loginCompletePromise, timeoutPromise]); + if (shouldLaunchBrowser()) { + const webLogin = await this.authWithWeb(oAuth2Client); + await open(webLogin.authUrl); + console.error('Waiting for authentication...'); + + // Add timeout to prevent infinite waiting when browser tab gets stuck + const authTimeout = 5 * 60 * 1000; // 5 minutes timeout + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject( + new Error( + 'Authentication timed out after 5 minutes. The browser tab may have gotten stuck in a loading state. ' + + 'Please try again.', + ), + ); + }, authTimeout); + }); + await Promise.race([webLogin.loginCompletePromise, timeoutPromise]); + } else { + await this.authManual(oAuth2Client); + } await OAuthCredentialStorage.saveCredentials(oAuth2Client.credentials); this.client = oAuth2Client; From 99f65daa7cdfdff619b584d344f429260c4f4397 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Fri, 12 Dec 2025 13:05:36 -0800 Subject: [PATCH 3/3] fix(auth): enhance manual auth with CSRF validation and timeout - Update Cloud Function to include CSRF token in credentials JSON. - Update AuthManager to validate CSRF token in manual flow. - Add 10-minute timeout to manual authentication to prevent hanging. --- cloud_function/index.js | 17 +++++++++++++++-- workspace-server/src/auth/AuthManager.ts | 14 +++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/cloud_function/index.js b/cloud_function/index.js index cef7371..8be095f 100644 --- a/cloud_function/index.js +++ b/cloud_function/index.js @@ -122,13 +122,26 @@ async function handleCallback(req, res) { // --- Fallback to manual instructions --- - const credentialsJson = JSON.stringify({ + const credentials = { refresh_token: refresh_token, scope: scope, token_type: token_type, access_token: access_token, expiry_date: expiry_date - }, null, 2); // Pretty print JSON + }; + + if (state) { + try { + const payload = JSON.parse(Buffer.from(state, 'base64').toString('utf8')); + if (payload && payload.csrf) { + credentials.csrf_token_for_validation = payload.csrf; + } + } catch (e) { + // Ignore state parsing errors here, as we are just trying to enhance the credentials + } + } + + const credentialsJson = JSON.stringify(credentials, null, 2); // Pretty print JSON // 4. Display the JSON and add a copy button + instructions res.set('Content-Type', 'text/html'); diff --git a/workspace-server/src/auth/AuthManager.ts b/workspace-server/src/auth/AuthManager.ts index aef96da..9748b09 100644 --- a/workspace-server/src/auth/AuthManager.ts +++ b/workspace-server/src/auth/AuthManager.ts @@ -103,10 +103,22 @@ export class AuthManager { }); return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + rl.close(); + reject(new Error('Manual authentication timed out after 10 minutes. Please try again.')); + }, 10 * 60 * 1000); // 10 minutes + rl.question('Paste credentials JSON here: ', (answer) => { + clearTimeout(timeout); rl.close(); try { const tokens = JSON.parse(answer.trim()); + + if (tokens.csrf_token_for_validation !== csrfToken) { + reject(new Error('CSRF token mismatch. Authentication aborted.')); + return; + } + if (tokens.access_token) { client.setCredentials(tokens); logToFile('Manual authentication successful'); @@ -115,7 +127,7 @@ export class AuthManager { reject(new Error('Invalid credentials JSON: missing access_token')); } } catch (e) { - reject(new Error(`Failed to parse credentials JSON: ${e}`)); + reject(new Error(`Failed to parse credentials JSON: ${e instanceof Error ? e.message : String(e)}`)); } }); });