feat(seo): swap GSC auth from OAuth to a service account (shared google-auth.sh) #328

Merged
joshtronic merged 1 commit from feat/gsc-service-account-auth into master 2026-07-03 03:45:05 +00:00
Collaborator

Rights the ship on the SEO auth: the GSC OAuth refresh-token flow was a gap-fill from when the service account couldn't be added to Search Console / Analytics. It can now, so this moves to server-to-server service-account (JWT-bearer) auth — no user consent, no refresh token to babysit. The same credential powers the GA wire-up (#309).

What changed

  • lib/google-auth.sh (new)google_sa_access_token <scope> mints a scoped token from GOOGLE_SERVICE_ACCOUNT (base64 | file path | inline JSON) by signing a JWT with the SA key via openssl. Shared by GSC and (next) GA.
  • lib/gsc.shgsc_access_token delegates to google_sa_access_token (scope webmasters.readonly); dropped the OAuth token exchange.
  • lib/ceo.shceo_read_gsc gates on GOOGLE_SERVICE_ACCOUNT.
  • bin/tick.sh — sources google-auth.sh; the SEO opt-in gate + env are now GOOGLE_SERVICE_ACCOUNT (single var replaces the three GSC_OAUTH_*).
  • Removed bin/gsc-auth.sh (the browser-based OAuth minting tool) and GSC_OAUTH_* from .env.example.
  • Docs — CLAUDE.md, architecture.md, .env.example.

Tests

  • bin/test-google-auth.sh (new) — SA parsing (base64 / file / inline / unset / garbage) and JWT assembly, including verifying the produced signature against the SA public key (a throwaway RSA keypair; curl stubbed, no network).
  • test-ceo.sh updated to the new gate var.
  • check-sync.sh green.

Verified live

Against the real service account: mints a token, lists all 12 GSC domain properties, and a searchAnalytics query returns rows.

Env lockstep

GOOGLE_SERVICE_ACCOUNT must be set on the host — it already is. The now-dead GSC_OAUTH_* can be removed from .env whenever; nothing reads them anymore.

Next

This is item 3 (the swap). The GA client (lib/ga.sh, same google-auth.sh) + wiring GA data into the SEO reports is #309 — built to use GSC and/or GA per domain, whatever's available.

🤖 Generated with Claude Code

Rights the ship on the SEO auth: the GSC OAuth refresh-token flow was a gap-fill from when the service account couldn't be added to Search Console / Analytics. It can now, so this moves to server-to-server **service-account (JWT-bearer)** auth — no user consent, no refresh token to babysit. The same credential powers the GA wire-up (#309). ## What changed - **`lib/google-auth.sh` (new)** — `google_sa_access_token <scope>` mints a scoped token from `GOOGLE_SERVICE_ACCOUNT` (base64 | file path | inline JSON) by signing a JWT with the SA key via `openssl`. Shared by GSC and (next) GA. - **`lib/gsc.sh`** — `gsc_access_token` delegates to `google_sa_access_token` (scope `webmasters.readonly`); dropped the OAuth token exchange. - **`lib/ceo.sh`** — `ceo_read_gsc` gates on `GOOGLE_SERVICE_ACCOUNT`. - **`bin/tick.sh`** — sources `google-auth.sh`; the SEO opt-in gate + env are now `GOOGLE_SERVICE_ACCOUNT` (single var replaces the three `GSC_OAUTH_*`). - **Removed** `bin/gsc-auth.sh` (the browser-based OAuth minting tool) and `GSC_OAUTH_*` from `.env.example`. - **Docs** — CLAUDE.md, architecture.md, .env.example. ## Tests - **`bin/test-google-auth.sh` (new)** — SA parsing (base64 / file / inline / unset / garbage) and JWT assembly, including **verifying the produced signature against the SA public key** (a throwaway RSA keypair; curl stubbed, no network). - `test-ceo.sh` updated to the new gate var. - `check-sync.sh` green. ## Verified live Against the real service account: mints a token, lists **all 12** GSC domain properties, and a `searchAnalytics` query returns rows. ## Env lockstep `GOOGLE_SERVICE_ACCOUNT` must be set on the host — it already is. The now-dead `GSC_OAUTH_*` can be removed from `.env` whenever; nothing reads them anymore. ## Next This is item 3 (the swap). The GA client (`lib/ga.sh`, same `google-auth.sh`) + wiring GA data into the SEO reports is **#309** — built to use GSC and/or GA per domain, whatever's available. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(seo): swap GSC auth from OAuth to a service account (shared google-auth.sh)
All checks were successful
Lint / check-sync (push) Successful in 5s
Lint / check-sync (pull_request) Successful in 5s
85ca362d54
The GSC OAuth refresh-token flow was a gap-fill from when the service account
couldn't be added to Search Console / Analytics. It can now, so this rights the
ship: server-to-server JWT-bearer auth via a service account -- no user consent,
no refresh token to babysit. The same credential powers the GA wire-up (#309).

- lib/google-auth.sh (new): google_sa_access_token <scope> -- mints a scoped
  token from GOOGLE_SERVICE_ACCOUNT (base64 | file path | inline JSON) by signing
  a JWT with the SA key via openssl. Shared by GSC + (soon) GA.
- lib/gsc.sh: gsc_access_token now delegates to google_sa_access_token.
- lib/ceo.sh: ceo_read_gsc gates on GOOGLE_SERVICE_ACCOUNT.
- bin/tick.sh: source google-auth.sh; SEO opt-in gate + env are GOOGLE_SERVICE_ACCOUNT.
- Removed bin/gsc-auth.sh (the OAuth minting tool) + GSC_OAUTH_* from .env.example.
- bin/test-google-auth.sh (new): SA parsing (base64/file/inline) + JWT assembly,
  including verifying the signature against the SA public key. test-ceo.sh updated.
- Docs: CLAUDE.md, docs/architecture.md, .env.example.

Verified live end-to-end against the real SA: mints a token, lists all 12 GSC
domain properties, and a searchAnalytics query returns rows.

Env lockstep: GOOGLE_SERVICE_ACCOUNT must be set on the host (it already is); the
now-dead GSC_OAUTH_* can be removed from .env at leisure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KXPafSYJPY6XkWHHk1Lfs
Author
Collaborator

🤖 Review — COMMENT (automated)

CI for 85ca362d: success

Swaps GSC auth from the OAuth refresh-token flow to service-account JWT-bearer auth via a new shared lib/google-auth.sh. Focused, well under 400 lines, checklist matches the diff, CI green. One sourcing concern I can't verify from the diff keeps me from a clean approve.

Main reservation — transitive sourcing of google-auth.sh:

  • Before this PR, gsc_access_token was self-contained in lib/gsc.sh. Now it delegates to google_sa_access_token (in lib/google-auth.sh). That means every runtime entry point that sources gsc.sh (and, transitively, lib/ceo.sh via ceo_read_gsc) must now ALSO source google-auth.sh.
  • bin/tick.sh is correctly updated (new . "$AGENT_HOME/lib/google-auth.sh" before gsc.sh). But if ceo.sh/gsc.sh are sourced from any other binary (e.g. a standalone CEO/report runner), a missing source there would make google_sa_access_token a "command not found" at runtime → gsc_access_token returns non-zero → silently degrades to the "token mint failed / not configured" path. It wouldn't crash, so it would go unnoticed.
  • This is invisible to the tests: test-ceo.sh stubs gsc_access_token, so the delegation is never exercised there. Please confirm no other entry point sources gsc.sh/ceo.sh without also picking up google-auth.sh.

Things that look right:

  • lib/google-auth.sh signing path is sound: JWT header/claims are base64url'd, signed with openssl dgst -sha256 -sign <(...) (key via process substitution, never hits disk or logs), and --data-urlencode assertion=... is safe.
  • google_sa_json resolution order (file path → valid-inline-JSON → base64) is reasonable and returns rc=1 on unset/garbage.
  • jq -r '.private_key' correctly unescapes the JSON \n into real PEM newlines.
  • .env.example, CLAUDE.md, docs/architecture.md, and the gsc-auth.sh removal are all consistent with the single-var (GOOGLE_SERVICE_ACCOUNT) model; the old three GSC_OAUTH_* are fully purged from the touched files.

Test coverage — strong: test-google-auth.sh covers all four SA-parsing shapes plus garbage, and crucially verifies the produced JWT signature against the SA public key (real RS256), header alg, all claims, and exp > iat, with curl stubbed (no network). Skip-safe on missing deps. Nicely done.

No security or correctness defects found in the code itself — just want a human to confirm the sourcing is complete across all entry points before merge.


Independent review by the harness on claude-opus-4-8 (effort: high). The human reviewer is requested once Igor has reviewed; a human still merges.

### 🤖 Review — `COMMENT` _(automated)_ CI for `85ca362d`: **success** Swaps GSC auth from the OAuth refresh-token flow to service-account JWT-bearer auth via a new shared `lib/google-auth.sh`. Focused, well under 400 lines, checklist matches the diff, CI green. One sourcing concern I can't verify from the diff keeps me from a clean approve. **Main reservation — transitive sourcing of `google-auth.sh`:** - Before this PR, `gsc_access_token` was self-contained in `lib/gsc.sh`. Now it delegates to `google_sa_access_token` (in `lib/google-auth.sh`). That means *every* runtime entry point that sources `gsc.sh` (and, transitively, `lib/ceo.sh` via `ceo_read_gsc`) must now ALSO source `google-auth.sh`. - `bin/tick.sh` is correctly updated (new `. "$AGENT_HOME/lib/google-auth.sh"` before `gsc.sh`). But if `ceo.sh`/`gsc.sh` are sourced from any other binary (e.g. a standalone CEO/report runner), a missing source there would make `google_sa_access_token` a "command not found" at runtime → `gsc_access_token` returns non-zero → silently degrades to the "token mint failed / not configured" path. It wouldn't crash, so it would go unnoticed. - This is invisible to the tests: `test-ceo.sh` stubs `gsc_access_token`, so the delegation is never exercised there. Please confirm no other entry point sources `gsc.sh`/`ceo.sh` without also picking up `google-auth.sh`. **Things that look right:** - `lib/google-auth.sh` signing path is sound: JWT header/claims are base64url'd, signed with `openssl dgst -sha256 -sign <(...)` (key via process substitution, never hits disk or logs), and `--data-urlencode assertion=...` is safe. - `google_sa_json` resolution order (file path → valid-inline-JSON → base64) is reasonable and returns rc=1 on unset/garbage. - `jq -r '.private_key'` correctly unescapes the JSON `\n` into real PEM newlines. - `.env.example`, `CLAUDE.md`, `docs/architecture.md`, and the `gsc-auth.sh` removal are all consistent with the single-var (`GOOGLE_SERVICE_ACCOUNT`) model; the old three `GSC_OAUTH_*` are fully purged from the touched files. **Test coverage — strong:** `test-google-auth.sh` covers all four SA-parsing shapes plus garbage, and crucially verifies the produced JWT signature against the SA public key (real RS256), header alg, all claims, and `exp > iat`, with curl stubbed (no network). Skip-safe on missing deps. Nicely done. No security or correctness defects found in the code itself — just want a human to confirm the sourcing is complete across all entry points before merge. --- <sub>Independent review by the harness on `claude-opus-4-8` (effort: high). The human reviewer is requested once Igor has reviewed; a human still merges.</sub> <!-- review sha=85ca362d54ccaa8532e40b5a0bb313904a01279b verdict=COMMENT ci=success -->
igor added spent time 2026-07-03 03:23:50 +00:00
46 seconds
joshtronic approved these changes 2026-07-03 03:45:01 +00:00
joshtronic deleted branch feat/gsc-service-account-auth 2026-07-03 03:45:05 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No assignees
2 participants
Notifications
Total time spent: 46 seconds
igor
46 seconds
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
joshtronic/igor!328
No description provided.