fix: googlechat auth token sourcing
This commit is contained in:
parent
ec4088c76c
commit
66c10e9562
@ -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. citeturn9view0turn6view0
|
||||
`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`). citeturn6view0
|
||||
- For headless systems (systemd, SSH-only), switch to file keyring + password (see `gog` docs). citeturn6view0
|
||||
- `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) citeturn9view0
|
||||
- `~/.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). citeturn6view0
|
||||
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`
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
48
extensions/googlechat/src/file-utils.ts
Normal file
48
extensions/googlechat/src/file-utils.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user