Secure Credential Storage and Token Refresh in gogcli: A Complete Guide
gogcli stores OAuth refresh tokens in the OS-native keyring using the 99designs/keyring library, with automatic backend selection and token validation via CheckRefreshToken before API calls.
The steipete/gogcli tool handles Google OAuth authentication for CLI workflows, requiring robust secure credential storage and token refresh in gogcli to protect sensitive refresh tokens across macOS, Linux, and headless environments. This architecture ensures that long-lived OAuth credentials never reside in plain text files unless explicitly configured for file-based storage.
How gogcli Secures OAuth Credentials
At the core of the credential management system is the secrets.Store interface defined in internal/secrets/store.go:18. This abstraction decouples the application logic from specific storage implementations, allowing gogcli to adapt to different operating system capabilities without code changes.
The concrete implementation, KeyringStore (internal/secrets/store.go:28), wraps the github.com/99designs/keyring library to provide OS-native encryption. This approach leverages macOS Keychain, Linux Secret Service (D-Bus), or Windows credential stores when available, falling back to an encrypted file backend only when necessary.
Backend Selection and Cross-Platform Support
The ResolveKeyringBackendInfo function (internal/secrets/store.go:77-94) determines which keyring backend to instantiate through a prioritized configuration hierarchy:
- Environment variable:
GOG_KEYRING_BACKENDoverrides all other settings - Configuration file:
config.KeyringBackendspecifies the backend in settings - Automatic detection: Defaults to "auto" to select the best available native backend
For Linux deployments, the openKeyring function (internal/secrets/store.go:59-87) includes critical headless support. When the DBUS_SESSION_BUS_ADDRESS environment variable is missing, the code forces the file backend via shouldForceFileBackend (lines 51-53) to prevent hanging on an unavailable Secret Service. This ensures reliable operation in Docker containers and SSH sessions.
Storing and Retrieving Tokens
Token persistence follows a strict serialization protocol to prevent credential leakage. The SetToken method (internal/secrets/store.go:11-50) handles storage:
- Normalizes email addresses and client names to lowercase
- Validates that refresh tokens are non-empty before storage
- Marshals a
storedTokenstruct (containing the sensitive refresh token) to JSON - Stores the payload under the deterministic key
token:<client>:<email>(or legacytoken:<email>for default clients)
The Token struct (internal/secrets/store.go:32-38) uses the json:"-" tag on the RefreshToken field to ensure the sensitive value never appears in logs or debug output when the struct is marshalled elsewhere. Only the storedToken wrapper includes the refresh token in its JSON representation for encrypted storage.
Retrieval via GetToken (internal/secrets/store.go:53-93) reverses this process, reading the JSON payload from the keyring and repopulating a Token value for application use.
Token Validation and Refresh
Before executing API calls, gogcli validates stored credentials to detect expired or revoked tokens. The CheckRefreshToken function in internal/googleauth/token_check.go:12-38 performs this validation:
- Constructs an
oauth2.Configusing client credentials read viareadClientCredentials - Creates a token source from the stored refresh token using
cfg.TokenSource - Attempts to retrieve a fresh access token via the OAuth2 exchange
- Applies a default 15-second timeout to prevent hanging network calls
If the exchange fails, the function returns an error indicating the refresh token is invalid, prompting the user to re-authenticate. This proactive validation prevents cryptic API errors during actual data operations.
For token import workflows, EmailForRefreshToken (internal/googleauth/token_email.go:13-41) retrieves the authorized email address associated with a raw refresh token. This function uses the https://www.googleapis.com/auth/userinfo.email scope to identify the account owner before storing the credential.
Practical Implementation Examples
Storing a New Token After Manual OAuth Flow
import (
"time"
"github.com/steipete/gogcli/internal/secrets"
"github.com/steipete/gogcli/internal/config"
)
func saveToken(email, refresh string) error {
// Open the default keyring store
store, err := secrets.OpenDefault()
if err != nil {
return err
}
// Build the token object
tok := secrets.Token{
Email: email,
RefreshToken: refresh,
Services: []string{"gmail"},
Scopes: []string{"https://www.googleapis.com/auth/gmail.readonly"},
CreatedAt: time.Now().UTC(),
}
// Persist under the default client name
return store.SetToken(config.DefaultClientName, email, tok)
}
Key implementation details: OpenDefault initializes the store at internal/secrets/store.go:49, while SetToken handles the JSON marshalling and keyring storage at lines 11-50.
Retrieving a Stored Token
func loadToken(email string) (secrets.Token, error) {
store, err := secrets.OpenDefault()
if err != nil {
return secrets.Token{}, err
}
return store.GetToken(config.DefaultClientName, email)
}
Key implementation: GetToken unmarshals the JSON payload from the keyring at internal/secrets/store.go:53-93.
Validating a Refresh Token Before Use
import (
"context"
"time"
"github.com/steipete/gogcli/internal/googleauth"
"github.com/steipete/gogcli/internal/config"
)
func validate(email string) error {
// Load the token first
tok, err := loadToken(email)
if err != nil {
return err
}
scopes := []string{"https://www.googleapis.com/auth/gmail.readonly"}
// Validate with 15s timeout
return googleauth.CheckRefreshToken(
context.Background(),
config.DefaultClientName,
tok.RefreshToken,
scopes,
15*time.Second,
)
}
Key implementation: CheckRefreshToken performs the OAuth2 exchange at internal/googleauth/token_check.go:12-38.
Resolving Email from a Raw Refresh Token
func emailFromRefresh(token string) (string, error) {
scopes := []string{"https://www.googleapis.com/auth/userinfo.email"}
return googleauth.EmailForRefreshToken(
context.Background(),
config.DefaultClientName,
token,
scopes,
15*time.Second,
)
}
Key implementation: EmailForRefreshToken queries the Google UserInfo endpoint at internal/googleauth/token_email.go:13-41.
Important Files and Architecture
| File | Purpose |
|---|---|
internal/secrets/store.go |
Core keyring wrapper implementing Store interface, token CRUD operations, and backend resolution logic |
internal/googleauth/token_check.go |
Implements CheckRefreshToken for OAuth2 validation and refresh token health checks |
internal/googleauth/token_email.go |
Implements EmailForRefreshToken to resolve account identity from refresh tokens |
internal/config/paths.go |
Ensures directory existence for the file-backend keyring via EnsureKeyringDir |
internal/config/keys.go |
Defines AppName used as the service label in OS keyring entries |
internal/googleauth/oauth_flow.go |
Performs interactive OAuth flows that produce refresh tokens for storage |
cmd/gog/main.go |
CLI entry point wiring authentication commands to the storage and validation layers |
These components collectively provide a secure, cross-platform credential store that automatically adapts to host capabilities and guarantees that refresh tokens are encrypted at rest and validated before use.
Summary
- gogcli leverages the OS-native keyring via
github.com/99designs/keyringto encrypt OAuth refresh tokens at rest, selecting macOS Keychain, Linux Secret Service, or encrypted file backends automatically. - The
KeyringStoreabstraction ininternal/secrets/store.goprovides CRUD operations (SetToken,GetToken,DeleteToken) with deterministic key naming (token:<client>:<email>) and JSON serialization that prevents credential leakage in logs. - Backend selection follows a strict hierarchy:
GOG_KEYRING_BACKENDenvironment variable overrides config file settings, with automatic fallback to a file backend when Linux D-Bus is unavailable to prevent hangs in headless environments. - Token validation occurs proactively via
CheckRefreshTokenininternal/googleauth/token_check.go, which performs an OAuth2 exchange with a 15-second timeout to verify refresh token validity before API operations. - The
Tokenstruct usesjson:"-"tags to ensure refresh tokens never appear in structured logs, while thestoredTokenwrapper handles encrypted persistence.
Frequently Asked Questions
How does gogcli choose which keyring backend to use?
The ResolveKeyringBackendInfo function in internal/secrets/store.go:77-94 implements a three-tier selection process. First, it checks the GOG_KEYRING_BACKEND environment variable. If unset, it reads the KeyringBackend value from the configuration file. If neither is specified, it defaults to "auto", which triggers automatic detection of the best available native backend (macOS Keychain, Linux Secret Service, or Windows credential store).
What happens if the Linux Secret Service is unavailable?
When running on Linux without a D-Bus session, the openKeyring function (internal/secrets/store.go:59-87) detects the missing DBUS_SESSION_BUS_ADDRESS environment variable and forces the file backend via shouldForceFileBackend (lines 51-53). This prevents the application from hanging indefinitely while waiting for an unavailable Secret Service, ensuring reliable operation in Docker containers, SSH sessions, and other headless Linux environments.
How does gogcli validate refresh tokens before use?
Before executing API calls, gogcli validates stored credentials using the CheckRefreshToken function in internal/googleauth/token_check.go:12-38. This function constructs an OAuth2 configuration from client credentials and attempts to exchange the stored refresh token for a fresh access token using cfg.TokenSource. The operation includes a 15-second timeout to prevent hanging on network failures. If the exchange fails, the function returns an error indicating the token is invalid or revoked, prompting re-authentication.
Where are tokens stored when no OS keyring is available?
When native OS keyrings are unavailable or explicitly disabled, gogcli falls back to an encrypted file backend. The openKeyring function handles this fallback in internal/secrets/store.go:59-87, creating the storage directory via EnsureKeyringDir from internal/config/paths.go. The file backend encrypts the token data at rest using the 99designs/keyring library's file implementation, ensuring that even without OS integration, credentials remain encrypted rather than stored in plain text.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →