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`)
|
### 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.
|
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:
|
1) Ensure `gog` is already authorized:
|
||||||
```bash
|
```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.
|
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
|
- `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). citeturn6view0
|
- 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.
|
- 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/`).
|
- The file keyring lives under your gog config directory (for example `~/.config/gogcli/keyring/`).
|
||||||
4) Verify `gog` is visible to the gateway user:
|
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:
|
Clawdbot reads `gog` OAuth client files from:
|
||||||
- `~/.config/gogcli/credentials.json`
|
- `~/.config/gogcli/credentials.json`
|
||||||
- `~/.config/gogcli/credentials-<client>.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.
|
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
|
### 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.
|
2) Use an OAuth 2.0 flow to request **offline** access and collect a refresh token.
|
||||||
- Required scopes for reactions include:
|
- Required scopes for reactions include:
|
||||||
- `https://www.googleapis.com/auth/chat.messages.reactions.create`
|
- `https://www.googleapis.com/auth/chat.messages.reactions.create`
|
||||||
|
|||||||
@ -161,8 +161,8 @@ function resolveUserAuthSource(params: {
|
|||||||
hasInlineRefresh || hasFileRefresh || hasEnvRefresh || hasEnvRefreshFile || hasGogRefresh;
|
hasInlineRefresh || hasFileRefresh || hasEnvRefresh || hasEnvRefreshFile || hasGogRefresh;
|
||||||
if (!hasClient || !hasRefresh) return "none";
|
if (!hasClient || !hasRefresh) return "none";
|
||||||
|
|
||||||
if (hasEnvClient || hasEnvClientFile || hasEnvRefresh || hasEnvRefreshFile) return "env";
|
|
||||||
if (hasFileClient || hasFileRefresh) return "file";
|
if (hasFileClient || hasFileRefresh) return "file";
|
||||||
|
if (hasEnvClient || hasEnvClientFile || hasEnvRefresh || hasEnvRefreshFile) return "env";
|
||||||
return "inline";
|
return "inline";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
|
|
||||||
import { GoogleAuth, OAuth2Client } from "google-auth-library";
|
import { GoogleAuth, OAuth2Client } from "google-auth-library";
|
||||||
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
|
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||||
|
import { readJsonFile, readRefreshTokenFromFile } from "./file-utils.js";
|
||||||
import { readGogRefreshTokenSync, resolveGogCredentialsFile } from "./gog.js";
|
import { readGogRefreshTokenSync, resolveGogCredentialsFile } from "./gog.js";
|
||||||
|
|
||||||
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
|
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 };
|
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 {
|
function resolveOAuthClientConfig(account: ResolvedGoogleChatAccount): OAuthClientConfig | null {
|
||||||
const cfg = account.config;
|
const cfg = account.config;
|
||||||
const gogAccount = cfg.gogAccount?.trim() || process.env[ENV_GOG_ACCOUNT]?.trim() || undefined;
|
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 gogAccount = cfg.gogAccount?.trim() || process.env[ENV_GOG_ACCOUNT]?.trim() || undefined;
|
||||||
const gogClient = cfg.gogClient?.trim() || process.env[ENV_GOG_CLIENT]?.trim() || undefined;
|
const gogClient = cfg.gogClient?.trim() || process.env[ENV_GOG_CLIENT]?.trim() || undefined;
|
||||||
if (cfg.oauthRefreshToken?.trim()) return cfg.oauthRefreshToken.trim();
|
if (cfg.oauthRefreshToken?.trim()) return cfg.oauthRefreshToken.trim();
|
||||||
|
|
||||||
const tokenFile = cfg.oauthRefreshTokenFile?.trim();
|
const tokenFile = cfg.oauthRefreshTokenFile?.trim();
|
||||||
if (tokenFile) {
|
if (tokenFile) {
|
||||||
const raw = readJsonFile(tokenFile);
|
const token = readRefreshTokenFromFile(tokenFile);
|
||||||
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
if (token) return token;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cfg.oauthFromGog) {
|
if (cfg.oauthFromGog) {
|
||||||
const token = readGogRefreshTokenSync({ gogAccount, gogClient });
|
const token = readGogRefreshTokenSync({ gogAccount, gogClient });
|
||||||
if (token) return token;
|
if (token) return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.accountId === DEFAULT_ACCOUNT_ID) {
|
if (account.accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
const envToken = process.env[ENV_OAUTH_REFRESH_TOKEN]?.trim();
|
const envToken = process.env[ENV_OAUTH_REFRESH_TOKEN]?.trim();
|
||||||
if (envToken) return envToken;
|
if (envToken) return envToken;
|
||||||
const envFile = process.env[ENV_OAUTH_REFRESH_TOKEN_FILE]?.trim();
|
const envFile = process.env[ENV_OAUTH_REFRESH_TOKEN_FILE]?.trim();
|
||||||
if (envFile) {
|
if (envFile) {
|
||||||
const raw = readJsonFile(envFile);
|
const token = readRefreshTokenFromFile(envFile);
|
||||||
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
if (token) return token;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { readJsonFile } from "./file-utils.js";
|
||||||
|
|
||||||
const tokenCache = new Map<string, string>();
|
const tokenCache = new Map<string, string>();
|
||||||
|
|
||||||
function resolveWildcardJsonFile(
|
function resolveWildcardJsonFile(
|
||||||
@ -61,15 +63,6 @@ function resolveGogJsonFile(
|
|||||||
return resolveWildcardJsonFile(dirs, baseName);
|
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[] {
|
function resolveConfigDirs(): string[] {
|
||||||
const dirs: string[] = [];
|
const dirs: string[] = [];
|
||||||
const xdg = process.env.XDG_CONFIG_HOME;
|
const xdg = process.env.XDG_CONFIG_HOME;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user