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:
- - Open your OS Keychain/Credential Manager.
- - Create a new secure entry (e.g., a "Generic Password" on macOS, a "Windows Credential", or similar on Linux).
- - Set the **Service** (or equivalent field) to:
${KEYCHAIN_SERVICE_NAME}
- - Set the **Account** (or username field) to:
${KEYCHAIN_ACCOUNT_NAME}
- - Paste the copied JSON into the **Password/Secret** field.
- - Save the entry.
+ - Click the "Copy JSON" button above.
+ - Paste the copied JSON into your terminal application where the extension is running.
+ - 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();
}
};