Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions cloud_function/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,17 +182,13 @@ async function handleCallback(req, res) {
<span id="copy-status">Copied!</span>

<div class="instructions">
<h4>Keychain Storage Instructions:</h4>
<h4>Instructions:</h4>
<ol>
<li>Open your OS Keychain/Credential Manager.</li>
<li>Create a new secure entry (e.g., a "Generic Password" on macOS, a "Windows Credential", or similar on Linux).</li>
<li>Set the **Service** (or equivalent field) to: <code>${KEYCHAIN_SERVICE_NAME}</code></li>
<li>Set the **Account** (or username field) to: <code>${KEYCHAIN_ACCOUNT_NAME}</code></li>
<li>Paste the copied JSON into the **Password/Secret** field.</li>
<li>Save the entry.</li>
<li>Click the "Copy JSON" button above.</li>
<li>Paste the copied JSON into your terminal application where the extension is running.</li>
<li>The extension will automatically save these credentials securely.</li>
</ol>
<p>Your local MCP server will now be able to find and use these credentials automatically.</p>
<p><small>(If keychain is unavailable, the server falls back to an encrypted file, but keychain is recommended.)</small></p>
<p><small>(Alternatively, you can manually save this JSON to your OS Keychain with Service: <code>${KEYCHAIN_SERVICE_NAME}</code> and Account: <code>${KEYCHAIN_ACCOUNT_NAME}</code>)</small></p>
</div>
</div>

Expand Down
92 changes: 75 additions & 17 deletions workspace-server/src/auth/AuthManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,6 +68,59 @@ export class AuthManager {
return false;
}

private async authManual(client: Auth.OAuth2Client): Promise<void> {
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<Auth.OAuth2Client> {
logToFile('getAuthenticatedClient called');

Expand Down Expand Up @@ -154,23 +208,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<never>((_, 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<never>((_, 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;
Expand Down
4 changes: 2 additions & 2 deletions workspace-server/src/utils/open-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const createMockChildProcess = () => ({
const openWrapper = async (url: string): Promise<any> => {
// 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();
}

Expand All @@ -42,7 +42,7 @@ const openWrapper = async (url: string): Promise<any> => {
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();
}
};
Expand Down