Skip to content

Commit 0149f29

Browse files
authored
Allow users to bypass keychain reading for headless (#757)
1 parent 0d54ea1 commit 0149f29

4 files changed

Lines changed: 157 additions & 6 deletions

File tree

cmd/auth/login.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ Examples:
5555
}
5656

5757
// LoginWithToken stores a token for an organization in the system keychain.
58+
// When the keychain is unavailable (e.g. BUILDKITE_NO_KEYRING=1 is set), it
59+
// still registers the org and selects it in config so that commands resolve the
60+
// org correctly; the caller is expected to supply the token via BUILDKITE_API_TOKEN.
5861
func LoginWithToken(f *factory.Factory, org, token string) error {
5962
if org == "" {
6063
return errors.New("--org is required when --token is provided")
@@ -64,13 +67,14 @@ func LoginWithToken(f *factory.Factory, org, token string) error {
6467
}
6568

6669
kr := keyring.New()
67-
if !kr.IsAvailable() {
68-
return errors.New("system keychain is not available; cannot store token")
69-
}
70-
if err := kr.Set(org, token); err != nil {
71-
return fmt.Errorf("failed to store token in keychain: %w", err)
70+
if kr.IsAvailable() {
71+
if err := kr.Set(org, token); err != nil {
72+
return fmt.Errorf("failed to store token in keychain: %w", err)
73+
}
74+
fmt.Println("Token stored securely in system keychain.")
75+
} else {
76+
fmt.Println("Keychain unavailable; token not stored. Use BUILDKITE_API_TOKEN to supply your token at runtime.")
7277
}
73-
fmt.Println("Token stored securely in system keychain.")
7478

7579
if err := f.Config.EnsureOrganization(org); err != nil {
7680
return fmt.Errorf("failed to register organization in config: %w", err)

internal/config/config_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"path/filepath"
66
"testing"
77

8+
"github.com/buildkite/cli/v3/pkg/keyring"
89
"github.com/spf13/afero"
910
)
1011

@@ -322,6 +323,37 @@ func TestConfig(t *testing.T) {
322323
})
323324
}
324325

326+
func TestAPITokenForOrgNoKeyring(t *testing.T) {
327+
// Ensure BUILDKITE_NO_KEYRING disables keychain access entirely and that
328+
// APITokenForOrg falls through to the config file (legacy) path without
329+
// attempting to call the OS keychain.
330+
setEnv(t, "BUILDKITE_NO_KEYRING", "1")
331+
setEnv(t, "CI", "")
332+
setEnv(t, "BUILDKITE", "")
333+
setEnv(t, "BUILDKITE_API_TOKEN", "")
334+
keyring.ResetForTesting()
335+
t.Cleanup(keyring.ResetForTesting)
336+
337+
fs := afero.NewMemMapFs()
338+
content := []byte("organizations:\n my-org:\n api_token: legacy-token\n")
339+
if err := afero.WriteFile(fs, configFile(), content, 0o600); err != nil {
340+
t.Fatalf("failed to write config: %v", err)
341+
}
342+
343+
conf := New(fs, nil)
344+
345+
// Should return the legacy file token without touching the keychain.
346+
if got := conf.APITokenForOrg("my-org"); got != "legacy-token" {
347+
t.Errorf("APITokenForOrg() = %q, want %q", got, "legacy-token")
348+
}
349+
350+
// Keyring must report unavailable.
351+
kr := keyring.New()
352+
if kr.IsAvailable() {
353+
t.Error("expected keyring to be unavailable when BUILDKITE_NO_KEYRING=1")
354+
}
355+
}
356+
325357
func TestExperiments(t *testing.T) {
326358
t.Run("defaults to empty", func(t *testing.T) {
327359
unsetEnv(t, "BUILDKITE_EXPERIMENTS")

pkg/keyring/keyring.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,22 @@ func MockForTesting() {
7070
})
7171
}
7272

73+
// ResetForTesting resets the availability cache so that the next call to
74+
// New() re-evaluates the environment. Intended for use in tests only.
75+
func ResetForTesting() {
76+
keyringAvailableOnce = sync.Once{}
77+
keyringAvailable = false
78+
}
79+
7380
// isKeyringAvailable checks if the system keyring can be used
7481
func isKeyringAvailable() bool {
7582
keyringAvailableOnce.Do(func() {
83+
// Disable keyring if explicitly opted out
84+
if os.Getenv("BUILDKITE_NO_KEYRING") != "" {
85+
keyringAvailable = false
86+
return
87+
}
88+
7689
// Disable keyring in CI environments
7790
if os.Getenv("CI") != "" || os.Getenv("BUILDKITE") != "" {
7891
keyringAvailable = false

pkg/keyring/keyring_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package keyring
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
// setEnv sets an environment variable for the duration of the test and
9+
// restores the original value (or unsets it) via t.Cleanup.
10+
func setEnv(t *testing.T, key, value string) {
11+
t.Helper()
12+
original, had := os.LookupEnv(key)
13+
if err := os.Setenv(key, value); err != nil {
14+
t.Fatalf("failed to set env %s: %v", key, err)
15+
}
16+
t.Cleanup(func() {
17+
if had {
18+
os.Setenv(key, original)
19+
} else {
20+
os.Unsetenv(key)
21+
}
22+
// Reset the once so the next test starts fresh.
23+
ResetForTesting()
24+
})
25+
// Reset now so this test sees the new env value.
26+
ResetForTesting()
27+
}
28+
29+
func TestIsKeyringAvailable(t *testing.T) {
30+
// These tests manipulate package-level state (sync.Once) so must not run
31+
// in parallel with each other.
32+
33+
t.Run("disabled by BUILDKITE_NO_KEYRING", func(t *testing.T) {
34+
setEnv(t, "BUILDKITE_NO_KEYRING", "1")
35+
setEnv(t, "CI", "")
36+
setEnv(t, "BUILDKITE", "")
37+
38+
kr := New()
39+
if kr.IsAvailable() {
40+
t.Error("expected keyring to be unavailable when BUILDKITE_NO_KEYRING is set")
41+
}
42+
})
43+
44+
t.Run("disabled by CI", func(t *testing.T) {
45+
setEnv(t, "CI", "true")
46+
setEnv(t, "BUILDKITE_NO_KEYRING", "")
47+
setEnv(t, "BUILDKITE", "")
48+
49+
kr := New()
50+
if kr.IsAvailable() {
51+
t.Error("expected keyring to be unavailable when CI is set")
52+
}
53+
})
54+
55+
t.Run("disabled by BUILDKITE", func(t *testing.T) {
56+
setEnv(t, "BUILDKITE", "true")
57+
setEnv(t, "BUILDKITE_NO_KEYRING", "")
58+
setEnv(t, "CI", "")
59+
60+
kr := New()
61+
if kr.IsAvailable() {
62+
t.Error("expected keyring to be unavailable when BUILDKITE is set")
63+
}
64+
})
65+
}
66+
67+
func TestNoKeyringGet(t *testing.T) {
68+
setEnv(t, "BUILDKITE_NO_KEYRING", "1")
69+
setEnv(t, "CI", "")
70+
setEnv(t, "BUILDKITE", "")
71+
72+
kr := New()
73+
token, err := kr.Get("my-org")
74+
if token != "" {
75+
t.Errorf("Get() returned non-empty token with keyring disabled, got %q", token)
76+
}
77+
if err == nil {
78+
t.Error("Get() expected ErrNotFound when keyring is disabled, got nil")
79+
}
80+
}
81+
82+
func TestNoKeyringSet(t *testing.T) {
83+
setEnv(t, "BUILDKITE_NO_KEYRING", "1")
84+
setEnv(t, "CI", "")
85+
setEnv(t, "BUILDKITE", "")
86+
87+
kr := New()
88+
if err := kr.Set("my-org", "token-123"); err != nil {
89+
t.Errorf("Set() returned unexpected error with keyring disabled: %v", err)
90+
}
91+
}
92+
93+
func TestNoKeyringDelete(t *testing.T) {
94+
setEnv(t, "BUILDKITE_NO_KEYRING", "1")
95+
setEnv(t, "CI", "")
96+
setEnv(t, "BUILDKITE", "")
97+
98+
kr := New()
99+
if err := kr.Delete("my-org"); err != nil {
100+
t.Errorf("Delete() returned unexpected error with keyring disabled: %v", err)
101+
}
102+
}

0 commit comments

Comments
 (0)