gmail-tools OAuth client secret rotation — local dev-machine sync

Trigger

Any gmail-tools script (gmail-query.js, send-digest.js, gmail-mutate.js, or any other script using lib/credentials-path.js) fails immediately with:

ERROR: invalid_client

This includes failing on a fresh node auth.js browser consent flow — a brand-new authorization code exchange still returns invalid_client. That rules out a stale/expired refresh token (which would instead show invalid_grant) and points at the OAuth client (id/secret pair) itself being rejected by Google.

How to confirm

  1. Check Google Cloud Console → APIs & Services → Credentials for the OAuth client (588063977139-d3o1pr8gulefpb3m4mimuu3k1lm7bao0.apps.googleusercontent.com as of 2026-08). Look at the client secrets list — if the secret matching what’s on disk shows Wyłączono / Disabled and a newer one shows Włączono / Enabled, the client was rotated and the local files were never updated.
  2. Confirm SOPS already has the current value (rotation usually updates SOPS as part of the rotation flow, but does not propagate to this dev machine’s local files — secrets-sync.yml only pushes to servers/Vercel, never to local dev credential files):
    $env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
    sops --decrypt --input-type dotenv --output-type dotenv secrets\gmail-tools.env.sops |
      ForEach-Object { $_.Split("=")[0] }
    Confirm GMAIL_TOOLS_GCP_OAUTH_CLIENT_ID / GMAIL_TOOLS_GCP_OAUTH_CLIENT_SECRET are present. Optionally cross-check the tail of google_tools_radieu_secret in a local .env.local fallback against the SOPS value’s tail (last 3-4 chars only, never full value) to confirm which one is current before trusting either.

Fix — resync local credential files from SOPS (values never printed)

Two files need patching, both on this Windows dev machine only:

  • %USERPROFILE%\.gmail-mcp\gcp-oauth.keys.json
  • C:\code_2026\gmail-tools\credentials\gcp-oauth.keys.json

Run in one PowerShell call (env vars don’t persist across separate tool calls):

$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$decrypted = sops --decrypt --input-type dotenv --output-type dotenv C:\code_2026\p24-infra\secrets\gmail-tools.env.sops
$newClientId = ($decrypted | Select-String "^GMAIL_TOOLS_GCP_OAUTH_CLIENT_ID=").ToString().Split("=",2)[1]
$newClientSecret = ($decrypted | Select-String "^GMAIL_TOOLS_GCP_OAUTH_CLIENT_SECRET=").ToString().Split("=",2)[1]
 
$paths = @(
  "$env:USERPROFILE\.gmail-mcp\gcp-oauth.keys.json",
  "C:\code_2026\gmail-tools\credentials\gcp-oauth.keys.json"
)
foreach ($p in $paths) {
  $json = Get-Content $p -Raw | ConvertFrom-Json
  $key = if ($json.installed) { "installed" } elseif ($json.web) { "web" } else { $null }
  if (-not $key) { Write-Output "SKIP (unexpected structure): $p"; continue }
  $json.$key.client_id = $newClientId
  $json.$key.client_secret = $newClientSecret
  [System.IO.File]::WriteAllText($p, ($json | ConvertTo-Json -Depth 10), [System.Text.UTF8Encoding]::new($false))
  Write-Output "Updated: $p"
}
$newClientId = $null; $newClientSecret = $null; $decrypted = $null

Then verify (no secret exposure):

Set-Location C:\code_2026\gmail-tools
node scripts\gmail-query.js --query "in:inbox newer_than:1d" --max 1

Real results (not invalid_client) confirm the fix.

If SOPS does not yet have the current value (rotation happened only in GCP Console, not synced to SOPS): pull the new secret from the Console’s download-JSON button, update secrets/gmail-tools.env.sops via the standard SOPS edit playbook (docs/playbooks/sops-edit-operations.md), canary-decrypt, commit/PR, then run the resync above.

Escalation

If node auth.js still fails with invalid_client after resyncing from a confirmed-current SOPS value, the OAuth client itself may have been deleted (not just secret-rotated) — this needs a human in Google Cloud Console to create a new Desktop-type OAuth client (redirect URI http://localhost:4343/callback) and update SOPS with the new id+secret.

Prevention

secrets-sync.yml has no local-dev-machine sync target — by design, local files are documented as “emergency fallback only; may be stale” ecosystem-wide. A GCP-console-triggered secret rotation (outside the normal SOPS-edit-then-sync flow) will always desync local dev files silently until something like this breaks. No automated fix planned; this playbook is the fix path. Consider adding a canary check (node scripts/gmail-query.js --max 1) to session-start checks for any session that plans to use gmail-tools, so this surfaces immediately rather than mid-task.