feat(auth): store credentials in OS keyring by default

What does this MR do?

Makes glab auth login store credentials in the operating system keyring by default when a backend is available (macOS Keychain, Windows Credential Manager, libsecret/Secret Service on Linux), matching the behavior of the GitHub CLI. When no keyring is available, credentials fall back to plaintext file storage.

Behavior

  • Keyring is the default at login; no flag is needed.
  • --insecure-storage forces plaintext file storage (following the gh CLI). --use-keyring is deprecated (keyring is now the default) but still accepted for backward compatibility; the two are mutually exclusive.
  • The choice is decided per-host at login time and persisted via use_keyring, so existing plaintext logins keep working until the user re-authenticates (no surprise keychain prompts, no breakage on upgrade).
  • If no keyring backend is present, a default login falls back to file storage with a warning.
  • In CI (when GITLAB_CI or CI is truthy) credentials are stored in the configuration file rather than the keyring. CI credentials are usually supplied via environment variables, and an OS keyring is often unavailable or not persisted across job steps.
  • Applies to all keyring-eligible secrets: token, job_token, and oauth2_refresh_token. Switching a host from keyring to file (or logging out) removes any keyring copy, so a secret is never orphaned.
  • glab auth status now names each host's storage backend ("operating system keyring", "configuration file (plaintext)", or "environment variable <NAME>") and nudges hosts still on plaintext to migrate.

Implementation

  • config.KeyringAvailable() probes for a usable keyring backend.
  • auth login computes the keyring preference from the flags, CI detection, and availability, then persists it before writing credentials in every auth path (token, job-token, web/device/OAuth).
  • Keyring read failures (locked/denied/unavailable) are surfaced instead of being silently treated as an empty credential; a genuinely missing entry is treated as unset.
  • If the OAuth2 refresh token cannot be read but a usable access token is present, the client falls through to access-token-only auth rather than failing the whole build.
  • config.SetStringValue clears a stale !!null YAML tag when writing a value, so a value written onto a blank entry (e.g. token:) is no longer left in the file as token: !!null <secret> (which leaked the secret in plaintext while decoding as empty).
  • The cryptic OAuth2 "no Refresh Token / Access Token required" error is replaced with an actionable "run glab auth login to re-authenticate" message.

How to test

Important

Test with the branch build (./bin/glab), not the glab already on your PATH. The released glab stores tokens in the config file by default (keyring is opt-in there), so running the wrong binary looks like "it didn't migrate".

make build
command -v glab          # confirm this is NOT the binary you intend to test
# optionally, so `glab` == this build for the session:
#   alias glab="$PWD/bin/glab"   (or `make install`)

Fresh login uses the keyring by default:

./bin/glab auth login --hostname gitlab.com --web
./bin/glab auth status --hostname gitlab.com     # -> "Token found in operating system keyring"
# token is in the keyring, not the file:
security find-generic-password -s "glab:gitlab.com:token" >/dev/null 2>&1 && echo "in keychain"
grep -n 'token:' ~/.config/glab-cli/config.yml    # host token line is empty

Migrating an existing plaintext host:

./bin/glab auth status --all                      # plaintext hosts show the migrate hint
./bin/glab auth login --hostname <plaintext-host> # re-auth moves it into the keyring
./bin/glab auth status --hostname <plaintext-host># now "operating system keyring"

Opt out / no keyring available:

./bin/glab auth login --hostname gitlab.com --token <PAT> --insecure-storage
grep -n 'token:' ~/.config/glab-cli/config.yml    # token stored as a quoted string, never !!null

CI defaults to the file (no keyring probe):

CI=true ./bin/glab auth login --hostname gitlab.com --token <PAT>
grep -n 'token:' ~/.config/glab-cli/config.yml    # stored in the file, even where a keyring exists

Tests

  • config: Test_KeyringAvailable, Test_InCI, Test_GetWithSource_SurfacesKeyringReadError, Test_GetWithSource_KeyringEnabledButTokenMissingReturnsEmpty, Test_SetStringValue_ResetsNullTagOnBlankEntry, Test_Set_SwitchingKeyringToFileRemovesStaleEntry, Test_Set_ClearRemovesKeyringEntry.
  • api: TestNewClientFromConfig_OAuth2NoTokenReturnsError (actionable message), TestNewClientFromConfig_SurfacesKeyringReadError.
  • auth login: Test_defaultKeyringLogin, Test_insecureStorageLogin, Test_keyringUnavailableFallsBackToFile, Test_useKeyringDeprecatedFallsBackToFileWhenUnavailable, Test_ciLoginDefaultsToFile, Test_ciLoginHonorsExplicitUseKeyring.
  • auth status: Test_statusRun_keyringMigrationHint plus updated golden output.
  • Verified end-to-end against the real macOS Keychain (stored, read back transparently, removed on logout).

Known follow-up

  • Storing OAuth credentials as a single keyring entry (access token + refresh token + expiry + is_oauth2) rather than splitting secrets across the keyring and the config file, to remove the "half-migrated" failure class entirely. Recommended as its own MR.

Closes #8327 (closed)

🤖 Generated with Claude Code

Edited by Stan Hu

Merge request reports

Loading