From 9012f3ec5ad080b59c0c1819e7dca9af4e30f89f Mon Sep 17 00:00:00 2001 From: Justin Poehnelt Date: Wed, 11 Feb 2026 13:40:13 -0700 Subject: [PATCH 1/2] feat: add support for headless OAuth authentication via credentials file --- README.md | 70 ++++++++++++++++++++++++ workspace-server/src/auth/AuthManager.ts | 56 +++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/README.md b/README.md index 9431615..cb5a49f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,76 @@ your terminal: gemini extensions install https://github.com/gemini-cli-extensions/workspace ``` +### Headless / Docker Authentication + +In environments without a browser (Docker, SSH, CI), you can provide OAuth +credentials via a JSON file using the `GEMINI_CLI_WORKSPACE_OAUTH_CREDENTIALS` +environment variable (similar to `GOOGLE_APPLICATION_CREDENTIALS`): + +```bash +GEMINI_CLI_WORKSPACE_OAUTH_CREDENTIALS=/path/to/credentials.json gemini +``` + +The simplest way to generate this file is with `gcloud`: + +#### 1. Create an OAuth Client ID + +Create a **Desktop** OAuth client in the +[Google Cloud Console → Credentials](https://console.cloud.google.com/apis/credentials): + +1. Click **Create Credentials** → **OAuth client ID** +2. Application type: **Desktop app** +3. Download the client secret JSON file (e.g. `client_secret.json`) + +#### 2. Enable Required APIs + +Enable the following APIs in your Google Cloud Project +([APIs & Services → Library](https://console.cloud.google.com/apis/library)): + +- [Google Calendar API](https://console.cloud.google.com/apis/api/calendar-json.googleapis.com) +- [Google Drive API](https://console.cloud.google.com/apis/api/drive.googleapis.com) +- [Google Docs API](https://console.cloud.google.com/apis/api/docs.googleapis.com) +- [Google Sheets API](https://console.cloud.google.com/apis/api/sheets.googleapis.com) +- [Google Slides API](https://console.cloud.google.com/apis/api/slides.googleapis.com) +- [Gmail API](https://console.cloud.google.com/apis/api/gmail.googleapis.com) +- [Google Chat API](https://console.cloud.google.com/apis/api/chat.googleapis.com) +- [People API](https://console.cloud.google.com/apis/api/people.googleapis.com) +- [Admin SDK API](https://console.cloud.google.com/apis/api/admin.googleapis.com) + +#### 3. Generate Credentials + +```bash +gcloud auth application-default login \ + --client-id-file=client_secret.json \ + --project=YOUR_PROJECT_ID \ + --scopes="\ +https://www.googleapis.com/auth/cloud-platform,\ +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" +``` + +You can customize the list of scopes used above to fit your needs. See the +[Google OAuth 2.0 Scopes](https://developers.google.com/identity/protocols/oauth2/scopes) +page for a list of available scopes. + +#### 4. Use the Credentials + +```bash +GEMINI_CLI_WORKSPACE_OAUTH_CREDENTIALS=~/.config/gcloud/application_default_credentials.json gemini +``` + +> **Note:** Both gcloud ADC format (`type: "authorized_user"`) and raw OAuth +> token JSON are supported. + ## Usage Once the extension is installed, you can use it to interact with your Google diff --git a/workspace-server/src/auth/AuthManager.ts b/workspace-server/src/auth/AuthManager.ts index 1aa8257..b7460db 100644 --- a/workspace-server/src/auth/AuthManager.ts +++ b/workspace-server/src/auth/AuthManager.ts @@ -6,6 +6,7 @@ import { google, Auth } from 'googleapis'; import crypto from 'node:crypto'; +import * as fs from 'node:fs'; import * as http from 'node:http'; import * as net from 'node:net'; import * as url from 'node:url'; @@ -84,6 +85,61 @@ export class AuthManager { public async getAuthenticatedClient(): Promise { logToFile('getAuthenticatedClient called'); + // If a credentials file is provided via env var, use it directly + // (similar to GOOGLE_APPLICATION_CREDENTIALS) + const credentialsFile = + process.env['GEMINI_CLI_WORKSPACE_OAUTH_CREDENTIALS']; + if (credentialsFile) { + // Return cached client on subsequent calls + if (this.client) { + return this.client; + } + + logToFile(`Loading credentials from file: ${credentialsFile}`); + let parsed: Record; + try { + const fileContents = await fs.promises.readFile( + credentialsFile, + 'utf-8', + ); + parsed = JSON.parse(fileContents); + } catch (e) { + throw new Error( + `Failed to read or parse credentials from ${credentialsFile}: ${e}`, + ); + } + + // Support gcloud ADC format (type: "authorized_user") + if (parsed.type === 'authorized_user') { + if ( + typeof parsed.client_id !== 'string' || + typeof parsed.client_secret !== 'string' || + typeof parsed.refresh_token !== 'string' + ) { + throw new Error( + `Malformed "authorized_user" credentials in ${credentialsFile}. ` + + `'client_id', 'client_secret', and 'refresh_token' must be strings.`, + ); + } + const oAuth2Client = new google.auth.OAuth2({ + clientId: parsed.client_id, + clientSecret: parsed.client_secret, + }); + oAuth2Client.setCredentials({ + refresh_token: parsed.refresh_token, + }); + this.client = oAuth2Client; + return oAuth2Client; + } + + // Otherwise treat as raw Auth.Credentials JSON + const creds = parsed as unknown as Auth.Credentials; + const oAuth2Client = new google.auth.OAuth2({ clientId: CLIENT_ID }); + oAuth2Client.setCredentials(creds); + this.client = oAuth2Client; + return oAuth2Client; + } + // Check if we have a cached client with valid credentials if ( this.client && From 0149807c4a7952e2342b1a0e3ff4029d06f7a121 Mon Sep 17 00:00:00 2001 From: Justin Poehnelt Date: Wed, 11 Feb 2026 13:52:08 -0700 Subject: [PATCH 2/2] refactor: extract credential loading logic into a private helper method --- workspace-server/src/auth/AuthManager.ts | 108 +++++++++++++---------- 1 file changed, 61 insertions(+), 47 deletions(-) diff --git a/workspace-server/src/auth/AuthManager.ts b/workspace-server/src/auth/AuthManager.ts index b7460db..eccb220 100644 --- a/workspace-server/src/auth/AuthManager.ts +++ b/workspace-server/src/auth/AuthManager.ts @@ -81,6 +81,63 @@ export class AuthManager { return false; } + /** + * Load an OAuth2 client from a credentials file specified by the + * GEMINI_CLI_WORKSPACE_OAUTH_CREDENTIALS environment variable. + * Supports gcloud ADC format (type: "authorized_user") and raw + * Auth.Credentials JSON. + */ + private async loadClientFromCredentialsFile( + credentialsFile: string, + ): Promise { + logToFile(`Loading credentials from file: ${credentialsFile}`); + let parsed: Record; + try { + const fileContents = await fs.promises.readFile( + credentialsFile, + 'utf-8', + ); + parsed = JSON.parse(fileContents); + } catch (e) { + throw new Error( + `Failed to read or parse credentials from ${credentialsFile}: ${(e as Error).message}`, + ); + } + + // Support gcloud ADC format (type: "authorized_user") + if (parsed.type === 'authorized_user') { + if ( + typeof parsed.client_id !== 'string' || + typeof parsed.client_secret !== 'string' || + typeof parsed.refresh_token !== 'string' + ) { + throw new Error( + `Malformed "authorized_user" credentials in ${credentialsFile}. ` + + `'client_id', 'client_secret', and 'refresh_token' must be strings.`, + ); + } + const oAuth2Client = new google.auth.OAuth2({ + clientId: parsed.client_id, + clientSecret: parsed.client_secret, + }); + oAuth2Client.setCredentials({ + refresh_token: parsed.refresh_token, + }); + return oAuth2Client; + } + + // Otherwise treat as raw Auth.Credentials JSON + const creds = parsed as Auth.Credentials; + if (!creds.access_token && !creds.refresh_token) { + throw new Error( + `Malformed credentials in ${credentialsFile}. ` + + `The file must contain at least 'access_token' or 'refresh_token'.`, + ); + } + const oAuth2Client = new google.auth.OAuth2({ clientId: CLIENT_ID }); + oAuth2Client.setCredentials(creds); + return oAuth2Client; + } public async getAuthenticatedClient(): Promise { logToFile('getAuthenticatedClient called'); @@ -90,54 +147,11 @@ export class AuthManager { const credentialsFile = process.env['GEMINI_CLI_WORKSPACE_OAUTH_CREDENTIALS']; if (credentialsFile) { - // Return cached client on subsequent calls - if (this.client) { - return this.client; - } - - logToFile(`Loading credentials from file: ${credentialsFile}`); - let parsed: Record; - try { - const fileContents = await fs.promises.readFile( - credentialsFile, - 'utf-8', - ); - parsed = JSON.parse(fileContents); - } catch (e) { - throw new Error( - `Failed to read or parse credentials from ${credentialsFile}: ${e}`, - ); + if (!this.client) { + this.client = + await this.loadClientFromCredentialsFile(credentialsFile); } - - // Support gcloud ADC format (type: "authorized_user") - if (parsed.type === 'authorized_user') { - if ( - typeof parsed.client_id !== 'string' || - typeof parsed.client_secret !== 'string' || - typeof parsed.refresh_token !== 'string' - ) { - throw new Error( - `Malformed "authorized_user" credentials in ${credentialsFile}. ` + - `'client_id', 'client_secret', and 'refresh_token' must be strings.`, - ); - } - const oAuth2Client = new google.auth.OAuth2({ - clientId: parsed.client_id, - clientSecret: parsed.client_secret, - }); - oAuth2Client.setCredentials({ - refresh_token: parsed.refresh_token, - }); - this.client = oAuth2Client; - return oAuth2Client; - } - - // Otherwise treat as raw Auth.Credentials JSON - const creds = parsed as unknown as Auth.Credentials; - const oAuth2Client = new google.auth.OAuth2({ clientId: CLIENT_ID }); - oAuth2Client.setCredentials(creds); - this.client = oAuth2Client; - return oAuth2Client; + return this.client; } // Check if we have a cached client with valid credentials