diff --git a/README.md b/README.md index 9431615b..633ab1e7 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,21 @@ Shows your schedule for today or a specified date. Searches your Google Drive for files matching the given query. +## Headless / Remote Environments + +If you're using the extension over SSH, WSL, Cloud Shell, or another environment +without a local browser, you can authenticate using the headless login tool: + +```bash +node scripts/auth-utils.js login +``` + +This prints an OAuth URL you can open in any browser (local machine, phone, +etc.). After signing in, paste the credentials JSON into the CLI. Credentials +are read securely from `/dev/tty` and are never exposed to the AI model. See the +[development docs](docs/development.md#headless--remote-environments) for more +details. + ## Deployment If you want to host your own version of this extension's infrastructure, see the diff --git a/cloud_function/index.js b/cloud_function/index.js index 575810d3..c59fdedb 100644 --- a/cloud_function/index.js +++ b/cloud_function/index.js @@ -192,17 +192,25 @@ async function handleCallback(req, res) { Copied!
-

Keychain Storage 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. -
-

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.)

+

CLI Login (Recommended):

+

In your terminal, run:

+
node dist/headless-login.js
+

Then paste the JSON above when prompted. The CLI will securely store your credentials.

+ +
+ Advanced: Manual Keychain Storage +
+
    +
  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. +
+

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

+
+
diff --git a/docs/development.md b/docs/development.md index fb1e21be..1dd6da4e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -155,6 +155,7 @@ used to maintain dot notation and avoid breaking existing configurations. - `src/`: Contains the source code for the server. - `__tests__/`: Contains all the tests. - `auth/`: Handles authentication. + - `cli/`: CLI tools (e.g., headless OAuth login). - `services/`: Contains the business logic for each service. - `utils/`: Contains utility functions. - `config/`: Contains configuration files. @@ -176,7 +177,32 @@ node scripts/auth-utils.js ### Commands +- `login`: Authenticate via headless OAuth flow (for SSH/WSL/Cloud Shell). Reads + credentials securely from `/dev/tty` so they are not visible to AI models. - `clear`: Clear all authentication credentials. - `expire`: Force the access token to expire (for testing refresh). - `status`: Show current authentication status. - `help`: Show the help message. + +### Headless / Remote Environments + +If you are running the server in an environment without a browser (SSH, WSL, +Cloud Shell, VMs), authentication requires manual steps: + +1. Run the login tool: + ```bash + node scripts/auth-utils.js login + ``` + Or, from the `workspace-server` directory: + ```bash + node dist/headless-login.js + ``` +2. Open the printed OAuth URL in any browser (your local machine, phone, etc.). +3. Complete Google sign-in. The browser will display a credentials JSON block. +4. Copy the JSON and paste it into the CLI when prompted. + +The CLI reads input from `/dev/tty` (Unix) or `CON` (Windows) rather than +process stdin, so credentials are never exposed to an AI model that may have +spawned the process. + +Use `--force` to re-authenticate if credentials already exist. diff --git a/docs/index.md b/docs/index.md index 0f1fef5e..e86ad356 100644 --- a/docs/index.md +++ b/docs/index.md @@ -93,6 +93,12 @@ The extension provides the following tools: assistant). Defaults to the authenticated user and supports filtering by relation type. +### Authentication + +- `auth.clear`: Clears authentication credentials, forcing a re-login on the + next request. +- `auth.refreshToken`: Manually triggers the token refresh process. + ## Custom Commands The extension includes several pre-configured commands for common tasks: diff --git a/scripts/auth-utils.js b/scripts/auth-utils.js index 0f57b7aa..d3e04728 100644 --- a/scripts/auth-utils.js +++ b/scripts/auth-utils.js @@ -74,6 +74,18 @@ async function showStatus() { } } +async function login() { + try { + require('../workspace-server/dist/headless-login.js'); + } catch (error) { + console.error( + '❌ Failed to load headless-login module. Run "npm run build:headless-login" first.', + ); + console.error(error.message); + process.exit(1); + } +} + function showHelp() { console.log(` Auth Management CLI @@ -81,12 +93,14 @@ Auth Management CLI Usage: node scripts/auth-utils.js Commands: + login Authenticate via headless OAuth flow (for SSH/WSL/Cloud Shell) clear Clear all authentication credentials expire Force the access token to expire (for testing refresh) status Show current authentication status help Show this help message Examples: + node scripts/auth-utils.js login node scripts/auth-utils.js clear node scripts/auth-utils.js expire node scripts/auth-utils.js status @@ -97,6 +111,9 @@ async function main() { const command = process.argv[2]; switch (command) { + case 'login': + await login(); + break; case 'clear': await clearAuth(); break; diff --git a/workspace-server/esbuild.headless-login.js b/workspace-server/esbuild.headless-login.js new file mode 100644 index 00000000..65f30010 --- /dev/null +++ b/workspace-server/esbuild.headless-login.js @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const esbuild = require('esbuild'); + +async function buildHeadlessLogin() { + try { + await esbuild.build({ + entryPoints: ['src/cli/headless-login.ts'], + bundle: true, + platform: 'node', + target: 'node20', + outfile: 'dist/headless-login.js', + minify: true, + sourcemap: true, + external: [ + 'keytar', // keytar is a native module and should not be bundled + ], + format: 'cjs', + logLevel: 'info', + }); + + console.log('Headless Login build completed successfully!'); + } catch (error) { + console.error('Headless Login build failed:', error); + process.exit(1); + } +} + +buildHeadlessLogin(); diff --git a/workspace-server/package.json b/workspace-server/package.json index d822dcff..b0f84c38 100644 --- a/workspace-server/package.json +++ b/workspace-server/package.json @@ -10,8 +10,9 @@ "test:ci": "cd .. && node --max-old-space-size=4096 node_modules/jest/bin/jest.js --ci --coverage --maxWorkers=2", "start": "ts-node src/index.ts", "clean": "rm -rf dist node_modules", - "build": "node esbuild.config.js", - "build:auth-utils": "node esbuild.auth-utils.js" + "build": "node esbuild.config.js && node esbuild.headless-login.js", + "build:auth-utils": "node esbuild.auth-utils.js", + "build:headless-login": "node esbuild.headless-login.js" }, "keywords": [], "author": "Allen Hutchison", diff --git a/workspace-server/src/auth/AuthManager.ts b/workspace-server/src/auth/AuthManager.ts index 1aa82571..70727cc0 100644 --- a/workspace-server/src/auth/AuthManager.ts +++ b/workspace-server/src/auth/AuthManager.ts @@ -174,6 +174,16 @@ export class AuthManager { } } + // Fail fast in headless environments instead of hanging for 5 minutes + if (!shouldLaunchBrowser()) { + throw new Error( + 'No browser available for authentication. ' + + 'Please run: node dist/headless-login.js\n' + + '(from the workspace-server directory)\n' + + 'After authenticating, retry your request.', + ); + } + const webLogin = await this.authWithWeb(oAuth2Client); await open(webLogin.authUrl); const msg = 'Waiting for authentication... Check your browser.'; diff --git a/workspace-server/src/auth/scopes.ts b/workspace-server/src/auth/scopes.ts new file mode 100644 index 00000000..244a38a1 --- /dev/null +++ b/workspace-server/src/auth/scopes.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OAuth scopes required by the Google Workspace MCP server. + * Shared between the MCP server and the headless login CLI. + */ +export const SCOPES = [ + 'https://www.googleapis.com/auth/documents', + 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/calendar', + 'https://www.googleapis.com/auth/chat.spaces', + 'https://www.googleapis.com/auth/chat.messages', + 'https://www.googleapis.com/auth/chat.memberships', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/directory.readonly', + 'https://www.googleapis.com/auth/presentations.readonly', + 'https://www.googleapis.com/auth/spreadsheets.readonly', +]; diff --git a/workspace-server/src/cli/headless-login.ts b/workspace-server/src/cli/headless-login.ts new file mode 100644 index 00000000..ea18f1f6 --- /dev/null +++ b/workspace-server/src/cli/headless-login.ts @@ -0,0 +1,239 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Headless OAuth login CLI tool. + * + * Allows users in headless environments (SSH, WSL, Cloud Shell, VMs) to + * complete the OAuth flow by: + * 1. Printing an OAuth URL to open in any browser + * 2. Reading pasted credentials JSON securely from /dev/tty (not stdin) + * 3. Saving credentials via OAuthCredentialStorage + * + * The /dev/tty approach ensures credentials are never visible to an AI model + * that may have spawned this process. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as readline from 'node:readline'; +import crypto from 'node:crypto'; +import { google } from 'googleapis'; +import { OAuthCredentialStorage } from '../auth/token-storage/oauth-credential-storage'; +import { SCOPES } from '../auth/scopes'; +import { loadConfig } from '../utils/config'; + +const config = loadConfig(); +const CLIENT_ID = config.clientId; +const CLOUD_FUNCTION_URL = config.cloudFunctionUrl; + +interface CredentialsJson { + access_token: string; + refresh_token: string; + expiry_date: number; + scope?: string; + token_type?: string; +} + +/** + * Opens a readable stream from /dev/tty (Unix) or CON (Windows). + * This bypasses process stdin entirely so credentials can't be intercepted + * by a parent process. + */ +function openTtyRead(): fs.ReadStream { + const ttyPath = os.platform() === 'win32' ? 'CON' : '/dev/tty'; + return fs.createReadStream(ttyPath, { encoding: 'utf8' }); +} + +/** + * Opens a writable stream to /dev/tty (Unix) or CON (Windows). + */ +function openTtyWrite(): fs.WriteStream { + const ttyPath = os.platform() === 'win32' ? 'CON' : '/dev/tty'; + return fs.createWriteStream(ttyPath); +} + +/** + * Reads multi-line input from /dev/tty until valid JSON is detected + * or the user presses Enter on an empty line. + */ +function readCredentialsFromTty(): Promise { + return new Promise((resolve, reject) => { + let input: fs.ReadStream; + let output: fs.WriteStream; + + try { + input = openTtyRead(); + output = openTtyWrite(); + } catch { + reject( + new Error( + 'Cannot open terminal for secure input. ' + + 'This command must be run in an interactive terminal.', + ), + ); + return; + } + + const rl = readline.createInterface({ input, output, terminal: false }); + const lines: string[] = []; + + output.write( + 'Paste the credentials JSON from the browser, then press Enter twice:\n', + ); + + rl.on('line', (line) => { + lines.push(line); + + // Try to parse accumulated input as JSON after each line + const joined = lines.join('\n').trim(); + if (joined) { + try { + JSON.parse(joined); + // Valid JSON detected, we're done + rl.close(); + return; + } catch { + // Not valid JSON yet, keep collecting + } + } + + // Empty line after content means user is done + if (line.trim() === '' && lines.length > 1) { + rl.close(); + } + }); + + rl.on('close', () => { + input.destroy(); + output.end(); + resolve(lines.join('\n').trim()); + }); + + rl.on('error', (err) => { + input.destroy(); + output.end(); + reject(err); + }); + }); +} + +/** + * Validates that the parsed JSON contains the required credential fields. + */ +function validateCredentials( + data: Record, +): asserts data is Record & CredentialsJson { + if (typeof data.access_token !== 'string' || !data.access_token) { + throw new Error('Missing or invalid "access_token" field.'); + } + if (typeof data.refresh_token !== 'string' || !data.refresh_token) { + throw new Error('Missing or invalid "refresh_token" field.'); + } + if (typeof data.expiry_date !== 'number' || !data.expiry_date) { + throw new Error('Missing or invalid "expiry_date" field.'); + } +} + +/** + * Generates the OAuth URL with manual=true state, matching the pattern + * used by AuthManager.authWithWeb(). + */ +function generateOAuthUrl(): string { + const csrfToken = crypto.randomBytes(32).toString('hex'); + + const statePayload = { + manual: true, + csrf: csrfToken, + }; + const state = Buffer.from(JSON.stringify(statePayload)).toString('base64'); + + const oAuth2Client = new google.auth.OAuth2({ clientId: CLIENT_ID }); + + return oAuth2Client.generateAuthUrl({ + redirect_uri: CLOUD_FUNCTION_URL, + access_type: 'offline', + scope: SCOPES, + state, + prompt: 'consent', + }); +} + +async function main() { + const force = process.argv.includes('--force'); + + // Check for existing credentials unless --force is used + if (!force) { + const existing = await OAuthCredentialStorage.loadCredentials(); + if (existing && existing.refresh_token) { + console.log('Already authenticated. Credentials found in storage.'); + console.log('Use --force to re-authenticate.'); + return; + } + } + + // Generate and display the OAuth URL + const authUrl = generateOAuthUrl(); + + console.log(); + console.log('=== Google Workspace MCP Server - Headless Login ==='); + console.log(); + console.log('Open this URL in any browser (local machine, phone, etc.):'); + console.log(); + console.log(authUrl); + console.log(); + console.log('After signing in, the browser will show your credentials JSON.'); + console.log('Copy that JSON and paste it below.'); + console.log(); + + // Read credentials securely from /dev/tty + const rawInput = await readCredentialsFromTty(); + + if (!rawInput) { + console.error('No input received.'); + process.exit(1); + } + + // Parse and validate + let parsed: Record; + try { + parsed = JSON.parse(rawInput); + } catch { + console.error( + 'Invalid JSON. Please copy the complete JSON from the browser.', + ); + process.exit(1); + } + + try { + validateCredentials(parsed); + } catch (err) { + console.error( + `Invalid credentials: ${err instanceof Error ? err.message : err}`, + ); + process.exit(1); + } + + // Save credentials + await OAuthCredentialStorage.saveCredentials({ + access_token: parsed.access_token as string, + refresh_token: parsed.refresh_token as string, + expiry_date: parsed.expiry_date as number, + scope: (parsed.scope as string) || SCOPES.join(' '), + token_type: (parsed.token_type as string) || 'Bearer', + }); + + console.log(); + console.log('Credentials saved successfully!'); + console.log('You can now start the MCP server.'); +} + +main().catch((error) => { + console.error('Login failed:', error.message || error); + process.exit(1); +}); diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index e2666a41..d2220d07 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -24,6 +24,7 @@ import { extractDocId } from './utils/IdUtils'; import { setLoggingEnabled } from './utils/logger'; import { applyToolNameNormalization } from './utils/tool-normalization'; +import { SCOPES } from './auth/scopes'; // Shared schemas for Gmail tools const emailComposeSchema = { @@ -46,24 +47,16 @@ const emailComposeSchema = { .describe('Whether the body is HTML (default: false).'), }; -const SCOPES = [ - 'https://www.googleapis.com/auth/documents', - 'https://www.googleapis.com/auth/drive', - 'https://www.googleapis.com/auth/calendar', - 'https://www.googleapis.com/auth/chat.spaces', - 'https://www.googleapis.com/auth/chat.messages', - 'https://www.googleapis.com/auth/chat.memberships', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/gmail.modify', - 'https://www.googleapis.com/auth/directory.readonly', - 'https://www.googleapis.com/auth/presentations.readonly', - 'https://www.googleapis.com/auth/spreadsheets.readonly', -]; - // Dynamically import version from package.json import { version } from '../package.json'; async function main() { + // Handle 'login' subcommand for headless OAuth flow + if (process.argv.includes('login')) { + await import('./cli/headless-login'); + return; + } + // 1. Initialize services if (process.argv.includes('--debug')) { setLoggingEnabled(true);