fix: googlechat auth token sourcing

This commit is contained in:
iHildy 2026-01-26 20:16:03 -06:00
parent ec4088c76c
commit 66c10e9562
5 changed files with 64 additions and 51 deletions

View File

@ -51,7 +51,7 @@ Service accounts cover most bot workflows, but **reactions and user-attributed a
### Option A: Use gog OAuth (recommended if you already use `gog`)
If you already use `gog` for Google Workspace, you can reuse its OAuth client + refresh token.
`gog` stores the OAuth client credentials JSON in your config directory and the refresh token in your system keyring. citeturn9view0turn6view0
`gog` stores the OAuth client credentials JSON in your config directory and the refresh token in your system keyring.
1) Ensure `gog` is already authorized:
```bash
@ -73,8 +73,8 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client +
}
```
3) Ensure `gog` can access its keyring on the gateway host.
- `gog` stores refresh tokens in the system keychain by default (not inside `credentials.json`). citeturn6view0
- For headless systems (systemd, SSH-only), switch to file keyring + password (see `gog` docs). citeturn6view0
- `gog` stores refresh tokens in the system keychain by default (not inside `credentials.json`).
- For headless systems (systemd, SSH-only), switch to file keyring + password (see `gog` docs).
- Set `GOG_KEYRING_BACKEND=file` and `GOG_KEYRING_PASSWORD=...` for the gateway service.
- The file keyring lives under your gog config directory (for example `~/.config/gogcli/keyring/`).
4) Verify `gog` is visible to the gateway user:
@ -87,12 +87,12 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client +
Clawdbot reads `gog` OAuth client files from:
- `~/.config/gogcli/credentials.json`
- `~/.config/gogcli/credentials-<client>.json`
- `~/.config/gogcli/credentials-<domain>.json` (or macOS equivalent) citeturn9view0
- `~/.config/gogcli/credentials-<domain>.json` (or macOS equivalent)
Clawdbot queries `gog auth tokens list --json` to discover which account to use, then runs `gog auth tokens export <email> --out <tmp>` to read the refresh token. If you have multiple gog accounts, set `gogAccount` (or `GOG_ACCOUNT`) to pick the right one. If this fails, set `oauthRefreshToken` manually.
### Option B: Manual OAuth
1) Configure OAuth consent + create OAuth client credentials in your Google Cloud project (desktop app recommended). citeturn6view0
1) Configure OAuth consent + create OAuth client credentials in your Google Cloud project (desktop app recommended).
2) Use an OAuth 2.0 flow to request **offline** access and collect a refresh token.
- Required scopes for reactions include:
- `https://www.googleapis.com/auth/chat.messages.reactions.create`

View File

@ -161,8 +161,8 @@ function resolveUserAuthSource(params: {
hasInlineRefresh || hasFileRefresh || hasEnvRefresh || hasEnvRefreshFile || hasGogRefresh;
if (!hasClient || !hasRefresh) return "none";
if (hasEnvClient || hasEnvClientFile || hasEnvRefresh || hasEnvRefreshFile) return "env";
if (hasFileClient || hasFileRefresh) return "file";
if (hasEnvClient || hasEnvClientFile || hasEnvRefresh || hasEnvRefreshFile) return "env";
return "inline";
}

View File

@ -1,9 +1,8 @@
import fs from "node:fs";
import { GoogleAuth, OAuth2Client } from "google-auth-library";
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { readJsonFile, readRefreshTokenFromFile } from "./file-utils.js";
import { readGogRefreshTokenSync, resolveGogCredentialsFile } from "./gog.js";
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
@ -93,16 +92,6 @@ function parseOAuthClientJson(raw: unknown): OAuthClientConfig | null {
return { clientId, clientSecret, redirectUri: redirect || undefined };
}
function readJsonFile(path: string): unknown | null {
try {
const raw = fs.readFileSync(path, "utf8");
if (!raw.trim()) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
function resolveOAuthClientConfig(account: ResolvedGoogleChatAccount): OAuthClientConfig | null {
const cfg = account.config;
const gogAccount = cfg.gogAccount?.trim() || process.env[ENV_GOG_ACCOUNT]?.trim() || undefined;
@ -154,42 +143,25 @@ function resolveOAuthRefreshToken(account: ResolvedGoogleChatAccount): string |
const gogAccount = cfg.gogAccount?.trim() || process.env[ENV_GOG_ACCOUNT]?.trim() || undefined;
const gogClient = cfg.gogClient?.trim() || process.env[ENV_GOG_CLIENT]?.trim() || undefined;
if (cfg.oauthRefreshToken?.trim()) return cfg.oauthRefreshToken.trim();
const tokenFile = cfg.oauthRefreshTokenFile?.trim();
if (tokenFile) {
const raw = readJsonFile(tokenFile);
if (typeof raw === "string" && raw.trim()) return raw.trim();
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>;
const token =
typeof record.refresh_token === "string"
? record.refresh_token.trim()
: typeof record.refreshToken === "string"
? record.refreshToken.trim()
: "";
if (token) return token;
}
const token = readRefreshTokenFromFile(tokenFile);
if (token) return token;
}
if (cfg.oauthFromGog) {
const token = readGogRefreshTokenSync({ gogAccount, gogClient });
if (token) return token;
}
if (account.accountId === DEFAULT_ACCOUNT_ID) {
const envToken = process.env[ENV_OAUTH_REFRESH_TOKEN]?.trim();
if (envToken) return envToken;
const envFile = process.env[ENV_OAUTH_REFRESH_TOKEN_FILE]?.trim();
if (envFile) {
const raw = readJsonFile(envFile);
if (typeof raw === "string" && raw.trim()) return raw.trim();
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>;
const token =
typeof record.refresh_token === "string"
? record.refresh_token.trim()
: typeof record.refreshToken === "string"
? record.refreshToken.trim()
: "";
if (token) return token;
}
const token = readRefreshTokenFromFile(envFile);
if (token) return token;
}
}
return null;

View File

@ -0,0 +1,48 @@
import fs from "node:fs";
/**
* Reads and parses a JSON file. Returns null if the file doesn't exist,
* is empty, or cannot be parsed. Logs meaningful errors for debugging.
*/
export function readJsonFile(filePath: string): unknown | null {
try {
const raw = fs.readFileSync(filePath, "utf8");
if (!raw.trim()) return null;
return JSON.parse(raw);
} catch (err) {
// Log meaningful errors (permission issues, malformed JSON) for debugging
if (err instanceof Error && err.message && !err.message.includes("ENOENT")) {
console.error(`Failed to read or parse JSON file ${filePath}: ${err.message}`);
}
return null;
}
}
/**
* Extracts a refresh token from a JSON object that may have either
* `refresh_token` or `refreshToken` property.
*/
export function extractRefreshTokenFromRecord(record: Record<string, unknown>): string | null {
const token =
typeof record.refresh_token === "string"
? record.refresh_token.trim()
: typeof record.refreshToken === "string"
? record.refreshToken.trim()
: undefined;
if (token && token.length > 0) return token;
return null;
}
/**
* Reads a refresh token from a file. The file may contain either:
* - A plain string token
* - A JSON object with refresh_token or refreshToken property
*/
export function readRefreshTokenFromFile(filePath: string): string | null {
const raw = readJsonFile(filePath);
if (typeof raw === "string" && raw.trim()) return raw.trim();
if (raw && typeof raw === "object") {
return extractRefreshTokenFromRecord(raw as Record<string, unknown>);
}
return null;
}

View File

@ -3,6 +3,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { readJsonFile } from "./file-utils.js";
const tokenCache = new Map<string, string>();
function resolveWildcardJsonFile(
@ -61,15 +63,6 @@ function resolveGogJsonFile(
return resolveWildcardJsonFile(dirs, baseName);
}
function readJsonFile(pathname: string): unknown | null {
try {
const raw = fs.readFileSync(pathname, "utf8");
return JSON.parse(raw);
} catch {
return null;
}
}
function resolveConfigDirs(): string[] {
const dirs: string[] = [];
const xdg = process.env.XDG_CONFIG_HOME;