Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
31 changes: 20 additions & 11 deletions cloud_function/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -182,17 +195,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
104 changes: 87 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,71 @@ 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) => {
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'));
}
Comment on lines +115 to +128
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current validation for the pasted credentials only checks for the presence of access_token. This could lead to issues if other essential fields like expiry_date are missing or have an incorrect type. Additionally, the entire tokens object, which includes the csrf_token_for_validation property, is passed to client.setCredentials(). While the library might ignore unknown properties, it's cleaner and safer to pass only the expected credential fields.

I suggest adding more robust validation for essential fields and cleaning the object before passing it to setCredentials to make the manual flow more robust.

                    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 && typeof tokens.access_token === 'string' && tokens.expiry_date && typeof tokens.expiry_date === 'number') {
                        // Exclude the CSRF validation token before setting credentials.
                        const { csrf_token_for_validation, ...validTokens } = tokens;
                        client.setCredentials(validTokens);
                        logToFile('Manual authentication successful');
                        resolve();
                    } else {
                        reject(new Error('Invalid credentials JSON: missing or invalid `access_token` or `expiry_date`.'));
                    }

} catch (e) {
reject(new Error(`Failed to parse credentials JSON: ${e instanceof Error ? e.message : String(e)}`));
}
});
});
}

public async getAuthenticatedClient(): Promise<Auth.OAuth2Client> {
logToFile('getAuthenticatedClient called');

Expand Down Expand Up @@ -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<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