Skip to content

Add service account authentication - #23

Merged
ndenny merged 2 commits into
developfrom
add-service-account-auth
Sep 1, 2026
Merged

Add service account authentication#23
ndenny merged 2 commits into
developfrom
add-service-account-auth

Conversation

@ndenny

@ndenny ndenny commented Aug 31, 2026

Copy link
Copy Markdown
Member

Summary

Adds support for the new Service Account functionality: point the CLI at the JSON file the APImetrics web app issues and it authenticates every request with the OAuth 2.0 client credentials grant instead of the browser-based sign-in. This is what makes the CLI usable from CI, cron jobs, and other headless environments.

apimetrics list-calls --service-account ./ci-runner.apimetrics.json
# or
export APIMETRICS_SERVICE_ACCOUNT=/secrets/ci-runner.apimetrics.json

The client_id, client_secret and audience come from the file; the token URL comes from the build config, so a QC service account works with the QC build. A token_url in the file overrides it if one ever appears there.

No refresh tokens

Verified against qc-auth.apimetrics.io: the client credentials grant issues no refresh token, and asking for offline_access is rejected outright.

scope=offline_access -> {"error":"access_denied","error_description":"Client has not been ..."}
no scope             -> {"access_token":"eyJ...","expires_in":3600,"token_type":"Bearer"}

This is per RFC 6749 4.4.3 — the credentials are themselves long-lived, so there is nothing for a refresh token to buy. The hour-long access token is cached and simply re-requested on expiry.

Tokens cache under service-account:<hash>, keyed on the credentials rather than the profile, so a service account never picks up the interactive login's token or another account's. The secret is part of the hash, so rotating it invalidates the cache instead of silently serving the old token.

--project-id

One addition beyond the auth change, flagged for review. Service accounts are granted access to specific projects, and a headless run can't answer ensureProject's interactive project picker — without this the first call in CI hangs on a prompt, which defeats the point. --project-id / <APP_NAME>_PROJECT_ID applies to a single run and never overwrites the project saved by project select. A non-TTY run with no project now fails with an actionable message rather than a dead prompt.

Bug found along the way

The first version resolved the service account in Init so auth was settled before Cobra ran, which meant GlobalFlags was parsed there and in Run. pflag appends on a repeat parse, so every -H and -q value was silently doubled (?foo=bar&foo=bar). The parse now stays exactly where it was, in Run, with the overrides applied immediately after it via applyCredentialOverrides().

Testing

go test ./... passes. New unit tests cover file loading and validation, cache-key derivation, the profile override, the token request shape, and the handler's caching and validation branches.

Manually verified end-to-end against QC with a real service account:

  • login, list-calls, list-schedules, list-account-projects, project show
  • flag and environment-variable forms
  • missing file / malformed JSON / missing fields / wrong secret all produce clear errors
  • fresh run makes 1 token POST; second run makes 0 and hits the cache
  • -H / -q no longer duplicated
  • interactive browser login path unaffected, and its cached token untouched by service account use

Also

.gitignore now covers the per-environment build outputs (apimetrics-qc, apimetrics-beta, ...). Both binary rules are anchored to the repo root so they can't swallow a same-named file deeper in the tree.

🤖 Generated with Claude Code

Authenticate with an APImetrics service account instead of the browser
login, which is what makes the CLI usable from CI and other headless
environments. Point `--service-account` (or the environment variable
`<APP_NAME>_SERVICE_ACCOUNT`) at the JSON file the web app issues and
every request uses the OAuth 2.0 client credentials grant.

The client credentials grant issues no refresh token (RFC 6749 4.4.3) and
Auth0 rejects `offline_access` for it, so the hour-long access token is
cached and simply re-requested on expiry. Tokens are cached under a key
derived from the credentials rather than the profile, so a service
account never picks up the interactive login's token or another
account's, and rotating a secret invalidates the cache instead of
silently serving the old token.

Also add `--project-id` / `<APP_NAME>_PROJECT_ID`. Service accounts are
granted access to specific projects and headless runs cannot answer the
interactive project picker, so without this the first call in CI hangs on
a prompt. The override applies to a single run and never overwrites the
project saved by `project select`; a non-TTY run with no project now
fails with an actionable message rather than a dead prompt.

Global flags are parsed exactly once, in Run. Parsing them a second time
would append to the repeatable flags, doubling every `-H` and `-q` value,
so the service account and project overrides are applied there rather
than in Init.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 31, 2026 23:50
@ndenny
ndenny enabled auto-merge August 31, 2026 23:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds headless authentication to the APImetrics CLI via a downloaded Service Account JSON file, enabling CI/cron usage without browser-based login, and introduces a non-interactive-friendly project selection override.

Changes:

  • Add an OAuth2 client-credentials based “service account” auth handler with token caching keyed by credentials.
  • Add --service-account / APIMETRICS_*_SERVICE_ACCOUNT and --project-id / APIMETRICS_*_PROJECT_ID to support headless runs and avoid interactive prompts.
  • Update documentation and gitignore to reflect new workflows and per-environment build outputs.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
README.md Documents service account usage, project override, env vars, and logout semantics.
oauth/serviceaccount.go Implements token source + request handler for service-account auth with cache-key override support.
oauth/serviceaccount_test.go Adds unit tests validating token request shape and handler caching behavior.
main.go Registers the new oauth-service-account auth scheme.
cli/state.go Adds per-run project override resolution and active project selection logic.
cli/serviceaccount.go Adds service account file loading/validation and credential-derived cache key generation.
cli/serviceaccount_test.go Adds tests for file loading/defaulting, cache key derivation, and profile override behavior.
cli/config.go Updates project selection behavior for non-TTY runs; adjusts login/logout messaging and service-account logout behavior.
cli/cli.go Ensures eager flag parsing happens once and applies service account + project overrides before first API call.
cli/apiconfig.go Refactors default profile init and adds applyCredentialOverrides() for service account + project header overrides.
.gitignore Anchors ignore rules for root-level environment-specific binaries.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cli/config.go
Comment thread cli/serviceaccount.go
Include the audience in the service account's token cache key. The file
format lets a service account name its own audience, so two files sharing
a client id, secret and token URL but naming different audiences would
otherwise share a cache entry and be handed a token minted for the wrong
audience.

Clear the `.refresh` entry in `logout --service-account` alongside the
rest. The client credentials grant issues no refresh token, but
TokenHandler writes one whenever the endpoint returns it and reads it
back when rebuilding a cached token, so leaving it behind would let a
stale entry outlive a logout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 31, 2026 23:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (4)

Previously missed (1) — in code that hasn't changed since the last review.

cli/serviceaccount_test.go:26

  • This test mutates the global buildCfg (TokenURL/AuthAudience) but never restores it, which can make other tests order-dependent if they rely on the default build config. Capture the previous values and restore them with t.Cleanup (or a defer) to keep the test isolated.

This issue also appears in the following locations of the same file:

  • line 47
  • line 60
  • line 117
	buildCfg.TokenURL = "https://auth.example.com/oauth/token"
	buildCfg.AuthAudience = "https://api.example.com"

cli/serviceaccount_test.go:49

  • This test mutates the global buildCfg (TokenURL/AuthAudience) but does not restore it, which can leak state into other tests in the package. Use t.Cleanup to restore prior values after the test completes.
	buildCfg.TokenURL = "https://auth.example.com/oauth/token"
	buildCfg.AuthAudience = "https://api.example.com"

cli/serviceaccount_test.go:61

  • This test changes the global buildCfg.TokenURL but never restores it, which can make other tests depend on execution order. Save the old value and restore it via t.Cleanup.
	buildCfg.TokenURL = "https://auth.example.com/oauth/token"

cli/serviceaccount_test.go:119

  • This test mutates the global buildCfg (TokenURL/AuthAudience) without restoring it, which risks order-dependent behavior in other tests that call Init/initAPIConfig. Restore the prior values with t.Cleanup.
	buildCfg.TokenURL = "https://auth.example.com/oauth/token"
	buildCfg.AuthAudience = "https://api.example.com"

@ndenny
ndenny merged commit 1b7d191 into develop Sep 1, 2026
2 checks passed
@ndenny
ndenny deleted the add-service-account-auth branch September 1, 2026 07:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants