From e383f3451183b2bd03454ee6befbadc82e0e93cf Mon Sep 17 00:00:00 2001 From: Karmel Allison Date: Wed, 11 Feb 2026 00:40:28 +0000 Subject: [PATCH] Allowing command line authentication with JSON copy-and-paste --- package-lock.json | 21 ++++++ workspace-server/src/auth/AuthManager.ts | 81 +++++++++++++++++----- workspace-server/src/index.ts | 52 ++++++++++++++ workspace-server/src/utils/open-wrapper.ts | 4 +- 4 files changed, 138 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3c269a9..4fe672b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -218,6 +218,7 @@ "integrity": "sha512-22SHEEVNjZfFWkFks3P6HilkR3rS7a6GjnCIqR22Zz4HNxdfT0FG+RE7efTcFVfLUkTTMQQybvaUcwMrHXYa7Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.46.0", "@algolia/requester-browser-xhr": "5.46.0", @@ -396,6 +397,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -989,6 +991,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1033,6 +1036,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3398,6 +3402,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3489,6 +3494,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -4283,6 +4289,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4384,6 +4391,7 @@ "integrity": "sha512-7ML6fa2K93FIfifG3GMWhDEwT5qQzPTmoHKCTvhzGEwdbQ4n0yYUWZlLYT75WllTGJCJtNUI0C1ybN4BCegqvg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.12.0", "@algolia/client-abtesting": "5.46.0", @@ -4988,6 +4996,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -6201,6 +6210,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6701,6 +6711,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -6930,6 +6941,7 @@ "integrity": "sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.3.0" } @@ -7536,6 +7548,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -8371,6 +8384,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -10322,6 +10336,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11955,6 +11970,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12129,6 +12145,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12357,6 +12374,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12654,6 +12672,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -13186,6 +13205,7 @@ "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", @@ -13667,6 +13687,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/workspace-server/src/auth/AuthManager.ts b/workspace-server/src/auth/AuthManager.ts index 431407c..397a492 100644 --- a/workspace-server/src/auth/AuthManager.ts +++ b/workspace-server/src/auth/AuthManager.ts @@ -39,6 +39,34 @@ export class AuthManager { this.scopes = scopes; } + public async isAuthenticated(): Promise { + const credentials = await OAuthCredentialStorage.loadCredentials(); + return !!(credentials && credentials.refresh_token); + } + + public async saveCredentialsFromJson(jsonStr: string): Promise { + try { + const tokens = JSON.parse(jsonStr); + // Validate input has required fields + if (!tokens.refresh_token || !tokens.access_token) { + throw new Error('Invalid credentials JSON: missing required fields'); + } + + this.client = new google.auth.OAuth2(CLIENT_ID); + this.client.setCredentials(tokens); + await OAuthCredentialStorage.saveCredentials(tokens); + logToFile('Credentials saved manually from JSON'); + } catch (e) { + logToFile(`Error saving credentials from JSON: ${e}`); + throw e; + } + } + + public async getAuthUrl(): Promise { + const client = new google.auth.OAuth2(CLIENT_ID); + return this.generateAuthUrl(client, true); // true = manual mode + } + public setOnStatusUpdate(callback: (message: string) => void) { this.onStatusUpdate = callback; } @@ -294,26 +322,20 @@ export class AuthManager { }); } - private async authWithWeb(client: Auth.OAuth2Client): Promise { - logToFile( - `Requesting authentication with scopes: ${this.scopes.join(', ')}`, - ); - - const port = await this.getAvailablePort(); - const host = process.env['OAUTH_CALLBACK_HOST'] || 'localhost'; - - const localRedirectUri = `http://${host}:${port}/oauth2callback`; - - const isGuiAvailable = shouldLaunchBrowser(); - - // SECURITY: Generate a random token for CSRF protection. - const csrfToken = crypto.randomBytes(32).toString('hex'); + private generateAuthUrl( + client: Auth.OAuth2Client, + manual: boolean, + localRedirectUri?: string, + csrfToken?: string, + ): string { + // Generate a new CSRF token if not provided + const token = csrfToken || crypto.randomBytes(32).toString('hex'); // The state now contains a JSON payload indicating the flow mode and CSRF token. const statePayload = { - uri: isGuiAvailable ? localRedirectUri : undefined, - manual: !isGuiAvailable, - csrf: csrfToken, + uri: localRedirectUri, + manual: manual, + csrf: token, }; const state = Buffer.from(JSON.stringify(statePayload)).toString('base64'); @@ -321,13 +343,36 @@ export class AuthManager { const cloudFunctionRedirectUri = 'https://google-workspace-extension.geminicli.com'; - const authUrl = client.generateAuthUrl({ + return 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 }); + } + + private async authWithWeb(client: Auth.OAuth2Client): Promise { + logToFile( + `Requesting authentication with scopes: ${this.scopes.join(', ')}`, + ); + + const port = await this.getAvailablePort(); + const host = process.env['OAUTH_CALLBACK_HOST'] || 'localhost'; + + const localRedirectUri = `http://${host}:${port}/oauth2callback`; + + const isGuiAvailable = shouldLaunchBrowser(); + + // SECURITY: Generate a random token for CSRF protection. + const csrfToken = crypto.randomBytes(32).toString('hex'); + + const authUrl = this.generateAuthUrl( + client, + !isGuiAvailable, + isGuiAvailable ? localRedirectUri : undefined, + csrfToken, + ); const loginCompletePromise = new Promise((resolve, reject) => { const server = http.createServer(async (req, res) => { diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index becce91..0cdc891 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -143,6 +143,58 @@ async function main() { }, ); + server.registerTool( + 'auth.login', + { + description: 'Initiates or completes the authentication flow.', + inputSchema: { + credentialsJson: z + .string() + .optional() + .describe( + 'The credentials JSON string pasted from the browser authentication page (if completing the flow).', + ), + }, + }, + async (input) => { + if (input.credentialsJson) { + // Complete the flow + await authManager.saveCredentialsFromJson(input.credentialsJson); + return { + content: [ + { + type: 'text', + text: 'Authentication successful! Credentials saved.', + }, + ], + }; + } + + // Check if already authenticated + if (await authManager.isAuthenticated()) { + return { + content: [ + { + type: 'text', + text: 'Already authenticated.', + }, + ], + }; + } + + // Initiate the flow + const authUrl = await authManager.getAuthUrl(); + return { + content: [ + { + type: 'text', + text: `To authenticate, please visit this URL:\n${authUrl}\n\nAfter authenticating, copy the JSON credentials and run this tool again with the 'credentialsJson' argument.`, + }, + ], + }; + }, + ); + server.registerTool( 'docs.create', { diff --git a/workspace-server/src/utils/open-wrapper.ts b/workspace-server/src/utils/open-wrapper.ts index e23bec7..33d6243 100644 --- a/workspace-server/src/utils/open-wrapper.ts +++ b/workspace-server/src/utils/open-wrapper.ts @@ -36,7 +36,7 @@ const createMockChildProcess = () => ({ const openWrapper = async (url: string): Promise => { // Check if we should launch the browser if (!shouldLaunchBrowser()) { - console.log( + console.error( `Browser launch not supported. Please open this URL in your browser: ${url}`, ); return createMockChildProcess(); @@ -47,7 +47,7 @@ const openWrapper = async (url: string): Promise => { await openBrowserSecurely(url); return createMockChildProcess(); } catch { - console.log( + console.error( `Failed to open browser. Please open this URL in your browser: ${url}`, ); return createMockChildProcess();