You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Make gog auth credentials setsecure 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:
Keyring Token struct (internal/secrets/store.go) has fields {Client, Subject, Email, Services, Scopes, CreatedAt, RefreshToken} — no ClientSecret field.
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:
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.
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:
gog auth credentials list (internal/config/clients.goListClientCredentials) 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").
gog auth credentials remove (DeleteClientCredentialsFor) currently os.Removes the file. Extended to also secrets.DeleteSecret(client/<name>/client-secret). Idempotent at both layers.
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.
What
Make
gog auth credentials setsecure by default: store the Google OAuthclient_secretin the keyring (behind the same99designs/keyringbackend 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 setwrites a flat{client_id, client_secret}JSON to disk at$XDG_CONFIG_HOME/gogcli/credentials.json(orcredentials-<client>.jsonfor named clients), mode 0600, parent dir 0700:config.WriteClientCredentialsFor(viajson.MarshalIndentof the flatClientCredentialsstruct).tokenSourceForAccountScopesininternal/googleapi/client_auth.goconstructsoauth2.Config{ClientID, ClientSecret, ...}from the on-disk file.Tokenstruct (internal/secrets/store.go) has fields{Client, Subject, Email, Services, Scopes, CreatedAt, RefreshToken}— noClientSecretfield.So the refresh token is encrypted at rest by the file-backend keyring (when
GOG_KEYRING_PASSWORDis set to a real passphrase); theclient_secret, by contrast, has only filesystem ACLs (0600 / 0700) as protection.Precedent already exists in this repo
The Zoom integration at
internal/zoom/store.godoes precisely what this issue asks for — for Zoom:On-disk metadata for Zoom holds only
{account_id, client_id, alias, scopes}(no secret). At runtime,LoadCredentialsreadsclient_idfrom the metadata file andclient_secretfrom the keyring (secrets.GetSecret(clientSecretKey(alias))), then assembles the in-memoryCredentialsstruct.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
credentials.jsonverbatim. Same threat model that refresh-token-in-keyring already mitigates — but only half the credential pair is protected.GOG_KEYRING_PASSWORD=""(the documented headless fallback atfileKeyringPasswordFuncininternal/secrets/store.go), keyring crypto is effectively obfuscation; with a real per-instance passphrase, refresh tokens become genuinely encrypted but theclient_secretnext to them does not.client_secretplaintext to disk to use Google OAuth, even when the surrounding plumbing already supports keyring-only storage for refresh tokens.Why this isn't covered by the existing flag
The
--access-tokenflag (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 ofclient_secretfor 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:.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 doctoractively diagnoses the file-keyring password posture (internal/cmd/auth_doctor.go), with distinct hints forGOG_KEYRING_PASSWORDset / 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-diskcredentials.json— there's no equivalent knob to check, because the file isn't behind the keyring at all. Movingclient_secretto the keyring brings it under the same diagnostic surface.docs/safety-profiles.md): baked binaries with embedded command policies, agent-safe defaults,--enable-commands/--disable-commands/--gmail-no-send/--wrap-untrustedflags. The threat model that justifies all of this — a process running asgogthat 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.internal/zoom/store.go). Refresh tokens, access tokens, andclient_secretall 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."
Flag name is a suggestion —
--insecuresignals 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:
$XDG_CONFIG_HOME/gogcli/credentials.jsonorcredentials-<name>.json): non-sensitive metadata only —{client_id}plus any auxiliary fields the diagnostic surface needs (see § "Diagnostic surface" below). Noclient_secret.client_secretat keyclient/<name>/client-secret(mirrors Zoom'szoom-account/<alias>/client-secret). Per-client namespacing keeps multi-client setups isolated, matching existing refresh-token bucketing (token:<client>:<email>).Read path
ReadClientCredentialsForreadsclient_idfrom disk,client_secretfrom 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 nextauth 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 viareadClientCredentialsand is unchanged. Three diagnostic / enumeration surfaces need adjustment:gog auth credentials list(internal/config/clients.goListClientCredentials) currently enumerates byos.ReadDir'ing the config dir and matchingcredentials*.jsonfilenames. With the secret in the keyring, the disk file still exists (holdingclient_id) so enumeration continues to work — only the listing'sPathfield semantics change ("metadata file path", not "credential file path").gog auth credentials remove(DeleteClientCredentialsFor) currentlyos.Removes the file. Extended to alsosecrets.DeleteSecret(client/<name>/client-secret). Idempotent at both layers.gog auth doctor,gog auth list) emitscredentials_pathandcredentials_exists. Add a siblingclient_secret_in_keyring: boolso operators can confirm the secret is where they expect it to be — and soauth doctorcan 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
installed/webenvelopes viaParseGoogleOAuthClientJSON).99designs/keyringvia the existingsecrets.Storeinterface.auth import/auth addsemantics; no change to the refresh flow itself.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
credentials.jsonfiles withclient_secretcontinue to work unchanged via the read-path fallback. The new shape applies on the nextauth credentials set. Operators who don't want to re-run the command get the old behavior indefinitely.Context
client_secret keyring,credentials encrypt,OAuth client secret storage,keyring credentials; discussions are disabled on this repo. Filing as a clean new ask.References
All references pinned to v0.17.0:
internal/config/credentials.go—ClientCredentialsstruct + on-disk write path (WriteClientCredentialsFor,ReadClientCredentialsFor).internal/cmd/auth_credentials.go—gog auth credentials setcommand entry point.internal/googleapi/client_auth.go—tokenSourceForAccountScopesreads the on-disk file on every refresh.internal/secrets/store.go—Tokenstruct (refresh-token-only),fileKeyringPasswordFunc(headless empty-passphrase mode).internal/cmd/auth_doctor.go— file-keyring-password diagnostics.internal/zoom/store.go— the model implementation: keyring-onlyclient_secretstorage for the Zoom path..agents/skills/gog/SKILL.md§ "Safety Rules" — the in-repo guidance that says OAuth client secrets must not be printed.docs/safety-profiles.md— baked binaries / agent-safety threat model.