Skip to content

Feature: store Google OAuth client_secret in the keyring (parity with the Zoom path) #596

@alexminza

Description

@alexminza

What

Make gog auth credentials set secure by default: store the Google OAuth client_secret in the keyring (behind the same 99designs/keyring backend that already protects refresh tokens), keep only non-sensitive metadata on disk. The Zoom integration in this repo already implements exactly this pattern — extending it to the Google OAuth path would bring the two flows to parity, eliminate the plaintext exposure surface that the maintainer's own safety guidance flags, and give third-party plugin authors a consistent at-rest crypto story without an opt-in step.

Why

Today's storage shape

For Google OAuth, gog auth credentials set writes a flat {client_id, client_secret} JSON to disk at $XDG_CONFIG_HOME/gogcli/credentials.json (or credentials-<client>.json for named clients), mode 0600, parent dir 0700:

So the refresh token is encrypted at rest by the file-backend keyring (when GOG_KEYRING_PASSWORD is set to a real passphrase); the client_secret, by contrast, has only filesystem ACLs (0600 / 0700) as protection.

Precedent already exists in this repo

The Zoom integration at internal/zoom/store.go does precisely what this issue asks for — for Zoom:

clientSecretKeyFmt   = "zoom-account/%s/client-secret"
accessTokenKeyFmt    = "zoom-account/%s/access-token"

On-disk metadata for Zoom holds only {account_id, client_id, alias, scopes} (no secret). At runtime, LoadCredentials reads client_id from the metadata file and client_secret from the keyring (secrets.GetSecret(clientSecretKey(alias))), then assembles the in-memory Credentials struct.

That pattern works, ships today, and uses the same backend as the existing Google refresh-token storage. Extending it to the Google path is mostly mechanical.

Project-side cost of the current shape

  • Plaintext exposure surface. Container image leaks, disk snapshots, debug dumps, and backup pipelines all capture credentials.json verbatim. Same threat model that refresh-token-in-keyring already mitigates — but only half the credential pair is protected.
  • Empty-passphrase trade-off is asymmetric. When GOG_KEYRING_PASSWORD="" (the documented headless fallback at fileKeyringPasswordFunc in internal/secrets/store.go), keyring crypto is effectively obfuscation; with a real per-instance passphrase, refresh tokens become genuinely encrypted but the client_secret next to them does not.
  • Third-party plugin authors hit this immediately. Anyone wiring a non-interactive credential-delivery RPC (the natural shape for headless / agentic deployments) has to ship the client_secret plaintext to disk to use Google OAuth, even when the surrounding plumbing already supports keyring-only storage for refresh tokens.
  • Inconsistent with peer integration. Zoom already does this; Google should match. Asymmetry forces consumers to learn two storage models.

Why this isn't covered by the existing flag

The --access-token flag (per-invocation) bypasses the entire refresh path, which is a different feature for a different use case (1h-life tokens, no refresh). It doesn't address the at-rest exposure of client_secret for the refresh flow.

This repo's own security posture argues for the change

gogcli already treats secret hygiene as a first-class concern. The same standards that justify the existing protections justify extending them to client_secret:

  • The bundled agent skill explicitly forbids leaking the very value this proposal moves to the keyring. .agents/skills/gog/SKILL.md § "Safety Rules" says: "Do not print access tokens, refresh tokens, OAuth client secrets, or keyring passwords." If agents must not print the client_secret, leaving it sitting in plaintext on disk is the same surface area inverted — the file gets picked up by image leaks, snapshots, and backup pipelines that the maintainer can't put a "don't print" rule on.
  • auth doctor actively diagnoses the file-keyring password posture (internal/cmd/auth_doctor.go), with distinct hints for GOG_KEYRING_PASSWORD set / empty / unset / no-TTY. That diagnostic infrastructure exists because the maintainer understands the file-backend crypto is only as strong as its passphrase. The same diagnostic doesn't (and can't) cover the on-disk credentials.json — there's no equivalent knob to check, because the file isn't behind the keyring at all. Moving client_secret to the keyring brings it under the same diagnostic surface.
  • The Safety Profiles system exists specifically to harden gogcli against agent / sandbox compromise (docs/safety-profiles.md): baked binaries with embedded command policies, agent-safe defaults, --enable-commands / --disable-commands / --gmail-no-send / --wrap-untrusted flags. The threat model that justifies all of this — a process running as gog that should not be able to escalate beyond a narrow scope — is the same threat model that justifies not leaving the OAuth client secret in plaintext next to it.
  • The Zoom path already implements the proposed pattern (internal/zoom/store.go). Refresh tokens, access tokens, and client_secret all live in the keyring; only non-sensitive metadata is on disk. The asymmetry between the Zoom and Google paths is the strongest argument: the maintainer has already decided the right shape; this issue just asks for parity.

Suggested shape

Mirror the Zoom path. Keyring storage becomes the default; on-disk plaintext becomes opt-out via flag. Secure-by-default is the right posture for a value the maintainer already documents as "must not be printed."

# Default: client_secret to keyring, non-sensitive metadata to disk
gog auth credentials set <path|-> --client=<name>

# Opt-out for back-compat with external tooling reading credentials.json directly
gog auth credentials set <path|-> --client=<name> --insecure

Flag name is a suggestion — --insecure signals the opt-out is a deliberate security downgrade, matching the OpenSSH-style convention where security-relaxing flags are explicit about the trade-off. Alternatives: --legacy (back-compat framing), --no-keyring (mechanism-describing). Whichever the maintainer prefers; the load-bearing part is that the default flips, not the flag name.

Storage layout (default mode)

Mirrors Zoom:

  • Disk ($XDG_CONFIG_HOME/gogcli/credentials.json or credentials-<name>.json): non-sensitive metadata only — {client_id} plus any auxiliary fields the diagnostic surface needs (see § "Diagnostic surface" below). No client_secret.
  • Keyring: client_secret at key client/<name>/client-secret (mirrors Zoom's zoom-account/<alias>/client-secret). Per-client namespacing keeps multi-client setups isolated, matching existing refresh-token bucketing (token:<client>:<email>).

Read path

ReadClientCredentialsFor reads client_id from disk, client_secret from keyring. Fallback: if the on-disk file contains both (legacy installs), use them directly without touching the keyring. Migration is automatic — existing installs keep working until the next auth credentials set, which writes the new shape.

Affected code paths

The change is localized. Most of the codebase reads the assembled ClientCredentials{ClientID, ClientSecret} struct via readClientCredentials and is unchanged. Three diagnostic / enumeration surfaces need adjustment:

  1. gog auth credentials list (internal/config/clients.go ListClientCredentials) currently enumerates by os.ReadDir'ing the config dir and matching credentials*.json filenames. With the secret in the keyring, the disk file still exists (holding client_id) so enumeration continues to work — only the listing's Path field semantics change ("metadata file path", not "credential file path").
  2. gog auth credentials remove (DeleteClientCredentialsFor) currently os.Removes the file. Extended to also secrets.DeleteSecret(client/<name>/client-secret). Idempotent at both layers.
  3. Diagnostic surface (gog auth doctor, gog auth list) emits credentials_path and credentials_exists. Add a sibling client_secret_in_keyring: bool so operators can confirm the secret is where they expect it to be — and so auth doctor can flag a "no secret found anywhere" failure mode clearly.

Refresh flow, authorization flow, account-client mapping, domain mapping, and service-account flow are untouched.

Scope

  • Google OAuth path only (installed / web envelopes via ParseGoogleOAuthClientJSON).
  • Reuse 99designs/keyring via the existing secrets.Store interface.
  • No new env vars introduced; no change to auth import / auth add semantics; no change to the refresh flow itself.
  • Service-account JSON (auth service-account set --key=<path>) is a separate code path and out of scope here — it has its own at-rest exposure but a different threat model and a much larger blob.

Out of scope

  • Forced migration of existing installs — existing credentials.json files with client_secret continue to work unchanged via the read-path fallback. The new shape applies on the next auth credentials set. Operators who don't want to re-run the command get the old behavior indefinitely.

Context

  • No existing issue, PR, or discussion proposes this — searched for client_secret keyring, credentials encrypt, OAuth client secret storage, keyring credentials; discussions are disabled on this repo. Filing as a clean new ask.
  • Relates to (but is independent of) feat(auth): add Application Default Credentials (ADC) auth mode #357 (ADC auth mode merged 2026-03-08) — that added a separate auth mode; this proposal touches storage of the OAuth-client-mode credentials.

References

All references pinned to v0.17.0:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions