diff --git a/cloud_function/index.js b/cloud_function/index.js index 75d8787..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'); @@ -182,17 +195,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 acd7f76..9748b09 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,71 @@ 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) => { + 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'); + resolve(); + } else { + reject(new Error('Invalid credentials JSON: missing access_token')); + } + } catch (e) { + reject(new Error(`Failed to parse credentials JSON: ${e instanceof Error ? e.message : String(e)}`)); + } + }); + }); + } + public async getAuthenticatedClient(): Promise { logToFile('getAuthenticatedClient called'); @@ -154,23 +220,27 @@ export class AuthManager { } } - const webLogin = await this.authWithWeb(oAuth2Client); - await open(webLogin.authUrl); - console.log('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; 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(); } };