Skip to content

Commit 8ed260b

Browse files
authored
REC-110: don't use keyring when an unencrypted file token is present (#60)
On Linux, the keyring library can prompt the user for a password if it's not automatically unlocked. This causes engflow_auth to hang when invoked as a credential helper because neither stdin nor stdout are connected to a terminal. With this change, the get command (and anything else that calls loadToken) now checks for a token created with -store=file first, then falls back to the keyring library if that's not present. This reverses the previous order. When storing a token into the keyring, storeToken now deletes the token created with -store=file, if present, preventing it from taking precedence. Together, these changes that mean when -store=file is used, the keyring library should only be used by the logout command, which attempts to delete both tokens.
1 parent ccd6177 commit 8ed260b

File tree

3 files changed

+128
-33
lines changed

3 files changed

+128
-33
lines changed

cmd/engflow_auth/main_test.go

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ func TestRun(t *testing.T) {
126126
wantUsageErr string
127127
wantStdoutContaining []string
128128
wantStderrContaining []string
129-
wantStored []string
129+
checkState func(t *testing.T, root *appState)
130130
}{
131131
{
132132
desc: "no subcommand",
@@ -226,6 +226,14 @@ func TestRun(t *testing.T) {
226226
fileStore: oauthtoken.NewFakeTokenStore().WithLoadErr(errors.New("fake error")),
227227
wantStdoutContaining: []string{`"x-engflow-auth-token"`},
228228
},
229+
{
230+
desc: "get from file does not call keyring",
231+
args: []string{"get"},
232+
machineInput: strings.NewReader(`{"uri": "https://cluster.example.com"}`),
233+
keyringStore: oauthtoken.NewFakeTokenStore().WithPanic("do not call"),
234+
fileStore: oauthtoken.NewFakeTokenStore().WithTokenForSubject("cluster.example.com", "alice"),
235+
wantStdoutContaining: []string{`"x-engflow-auth-token"`},
236+
},
229237
{
230238
desc: "version prints build metadata",
231239
args: []string{"version"},
@@ -265,9 +273,12 @@ func TestRun(t *testing.T) {
265273
VerificationURIComplete: "https://cluster.example.com/with/auth/code",
266274
},
267275
},
268-
wantStored: []string{
269-
"cluster.example.com",
270-
"cluster.local.example.com",
276+
checkState: func(t *testing.T, root *appState) {
277+
checkTokenStoreContains(
278+
t,
279+
root.keyringStore,
280+
"cluster.example.com",
281+
"cluster.local.example.com")
271282
},
272283
},
273284
{
@@ -418,6 +429,33 @@ func TestRun(t *testing.T) {
418429
},
419430
wantStderrContaining: []string{"Login identity has changed"},
420431
},
432+
{
433+
desc: "login with keyring deletes file",
434+
args: []string{"login", "cluster.example.com"},
435+
authenticator: &fakeAuth{
436+
deviceResponse: &oauth2.DeviceAuthResponse{
437+
VerificationURIComplete: "https://cluster.example.com/with/auth/code",
438+
},
439+
token: oauthtoken.NewFakeTokenForSubject("alice"),
440+
},
441+
fileStore: oauthtoken.NewFakeTokenStore().WithTokenForSubject("cluster.example.com", "alice"),
442+
checkState: func(t *testing.T, root *appState) {
443+
if _, err := root.fileStore.Load("cluster.example.com"); err == nil {
444+
t.Error("token was not deleted from file store")
445+
}
446+
},
447+
},
448+
{
449+
desc: "login with file should not call keyring",
450+
args: []string{"login", "-store=file", "cluster.example.com"},
451+
authenticator: &fakeAuth{
452+
deviceResponse: &oauth2.DeviceAuthResponse{
453+
VerificationURIComplete: "https://cluster.example.com/with/auth/code",
454+
},
455+
token: oauthtoken.NewFakeTokenForSubject("alice"),
456+
},
457+
keyringStore: oauthtoken.NewFakeTokenStore().WithPanic("do not call"),
458+
},
421459
{
422460
desc: "logout without cluster",
423461
args: []string{"logout"},
@@ -539,18 +577,21 @@ func TestRun(t *testing.T) {
539577
args: []string{"import"},
540578
machineInput: strings.NewReader(`{"token":{"access_token":"token_data"},"cluster_host":"cluster.example.com"}`),
541579
keyringStore: oauthtoken.NewFakeTokenStore(),
542-
wantStored: []string{
543-
"cluster.example.com",
580+
checkState: func(t *testing.T, root *appState) {
581+
checkTokenStoreContains(t, root.keyringStore, "cluster.example.com")
544582
},
545583
},
546584
{
547585
desc: "import with alias",
548586
args: []string{"import"},
549587
machineInput: strings.NewReader(`{"token":{"access_token":"token_data"},"cluster_host":"cluster.example.com","aliases":["cluster.local.example.com"]}`),
550588
keyringStore: oauthtoken.NewFakeTokenStore(),
551-
wantStored: []string{
552-
"cluster.example.com",
553-
"cluster.local.example.com",
589+
checkState: func(t *testing.T, root *appState) {
590+
checkTokenStoreContains(
591+
t,
592+
root.keyringStore,
593+
"cluster.example.com",
594+
"cluster.local.example.com")
554595
},
555596
},
556597
{
@@ -570,6 +611,23 @@ func TestRun(t *testing.T) {
570611
wantCode: autherr.CodeBadParams,
571612
wantErr: "token data contains invalid cluster",
572613
},
614+
{
615+
desc: "import with keyring deletes file",
616+
args: []string{"import"},
617+
machineInput: strings.NewReader(`{"token":{"access_token":"token_data"},"cluster_host":"cluster.example.com"}`),
618+
fileStore: oauthtoken.NewFakeTokenStore().WithTokenForSubject("cluster.example.com", "alice"),
619+
checkState: func(t *testing.T, root *appState) {
620+
if _, err := root.fileStore.Load("cluster.example.com"); err == nil {
621+
t.Error("token was not deleted from file store")
622+
}
623+
},
624+
},
625+
{
626+
desc: "import with file should not call keyring",
627+
args: []string{"import", "-store=file"},
628+
machineInput: strings.NewReader(`{"token":{"access_token":"token_data"},"cluster_host":"cluster.example.com"}`),
629+
keyringStore: oauthtoken.NewFakeTokenStore().WithPanic("do not call"),
630+
},
573631
}
574632
for _, tc := range testCases {
575633
t.Run(tc.desc, func(t *testing.T) {
@@ -633,9 +691,18 @@ func TestRun(t *testing.T) {
633691
t.Logf("\n====== BEGIN APP STDERR ======\n%s\n====== END APP STDERR ======\n\n", stderr.String())
634692
}
635693
}
636-
if tokenStore, ok := tc.keyringStore.(*oauthtoken.FakeTokenStore); ok {
637-
assert.Subset(t, tokenStore.Tokens, tc.wantStored)
694+
if tc.checkState != nil {
695+
tc.checkState(t, root)
638696
}
639697
})
640698
}
641699
}
700+
701+
func checkTokenStoreContains(t *testing.T, ts oauthtoken.LoadStorer, clusters ...string) {
702+
for _, cluster := range clusters {
703+
_, err := ts.Load(cluster)
704+
if err != nil {
705+
t.Error(err)
706+
}
707+
}
708+
}

cmd/engflow_auth/tokens.go

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"errors"
1919
"fmt"
2020
"io/fs"
21+
"os"
2122

2223
"github.com/EngFlow/auth/internal/oauthtoken"
2324
"github.com/golang-jwt/jwt/v5"
@@ -27,23 +28,39 @@ import (
2728
// loadToken loads a token for the given cluster or returns an error equivalent
2829
// to fs.ErrNotExist if the token is not found in any store.
2930
//
31+
// NOTE(REC-110): loadToken attempts to load from the file store first, falling
32+
// back to the keyring if an unencrypted token is not found. On Linux, the
33+
// keyring library may try to prompt the user for a password, hanging forever
34+
// because stdin and stdout are not normally connected to a terminal. We should
35+
// avoid calling it at all if the token is not stored there.
36+
//
3037
// loadToken may contain logic specific to this app and should be called
3138
// by commands instead of calling LoadStorer.Load directly.
3239
func (r *appState) loadToken(cluster string) (*oauth2.Token, error) {
33-
var errs []error
34-
backends := []oauthtoken.LoadStorer{r.keyringStore, r.fileStore}
35-
for _, backend := range backends {
36-
token, err := backend.Load(cluster)
37-
if err == nil {
40+
token, fileErr := r.fileStore.Load(cluster)
41+
if fileErr == nil {
42+
return token, nil
43+
}
44+
var keyringErr error
45+
if !r.writeFileStore {
46+
token, keyringErr = r.keyringStore.Load(cluster)
47+
if keyringErr == nil {
3848
return token, nil
3949
}
40-
errs = append(errs, err)
4150
}
42-
return nil, fmt.Errorf("failed to load token from %d backend(s): %w", len(backends), errors.Join(errs...))
51+
return nil, fmt.Errorf("failed to load token: %w", errors.Join(fileErr, keyringErr))
4352
}
4453

4554
// storeToken stores a token for the given cluster in one of the backends.
4655
//
56+
// NOTE(REC-110): when -store=file is used, storeToken only writes to the file
57+
// store and ignores the keyring store. When -store=file is not used,
58+
// storedToken writes to the keyring store deletes then token from the file
59+
// store if present. On Linux, the keyring library may try to prompt the user
60+
// for a password, causing loadToken to hang forever when invoked later.
61+
// So if -store=file is used, we should avoid calling the keyring library,
62+
// now or in the future.
63+
//
4764
// storeToken may contain logic specific to this app and should be called
4865
// by commands instead of calling LoadStorer.Store directly. For example,
4966
// storeToken prints a message if the token's subject has changed.
@@ -56,6 +73,9 @@ func (r *appState) storeToken(cluster string, token *oauth2.Token) error {
5673
if r.writeFileStore {
5774
return r.fileStore.Store(cluster, token)
5875
} else {
76+
if err := r.fileStore.Delete(cluster); err != nil && !errors.Is(err, fs.ErrNotExist) {
77+
fmt.Fprintf(os.Stderr, "warning: attempting to delete existing file token: %v", err)
78+
}
5979
return r.keyringStore.Store(cluster, token)
6080
}
6181
}
@@ -105,19 +125,7 @@ func (r *appState) deleteToken(cluster string) error {
105125
}
106126
}
107127
if err := errors.Join(nonNotFoundErrs...); err != nil {
108-
return fmt.Errorf("failed to delete token from %d backend(s): %w", len(backends), err)
128+
return fmt.Errorf("failed to delete token: %w", err)
109129
}
110-
return &multiBackendNotFoundError{backendsCount: len(backends)}
111-
}
112-
113-
type multiBackendNotFoundError struct {
114-
backendsCount int
115-
}
116-
117-
func (m *multiBackendNotFoundError) Error() string {
118-
return fmt.Sprintf("token for cluster not found after trying %d token storage backends", m.backendsCount)
119-
}
120-
121-
func (m *multiBackendNotFoundError) Is(err error) bool {
122-
return err == fs.ErrNotExist
130+
return errors.Join(errs...)
123131
}

internal/oauthtoken/fake.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,14 @@ import (
2626
// FakeTokenStore is a test implementation of LoadStorer that stores tokens in
2727
// memory instead of the system keychain.
2828
type FakeTokenStore struct {
29-
Tokens map[string]*oauth2.Token
29+
Tokens map[string]*oauth2.Token
30+
31+
// Error values to be returned by Load, Store, and Delete, if not nil.
3032
LoadErr, StoreErr, DeleteErr error
33+
34+
// Value to panic with in Load, Store, and Delete, if not nil.
35+
// Used to test that a method is NOT called.
36+
PanicValue any
3137
}
3238

3339
var _ LoadStorer = (*FakeTokenStore)(nil)
@@ -39,6 +45,9 @@ func NewFakeTokenStore() *FakeTokenStore {
3945
}
4046

4147
func (f *FakeTokenStore) Load(cluster string) (*oauth2.Token, error) {
48+
if f.PanicValue != nil {
49+
panic(f.PanicValue)
50+
}
4251
if f.LoadErr != nil {
4352
return nil, f.LoadErr
4453
}
@@ -50,6 +59,9 @@ func (f *FakeTokenStore) Load(cluster string) (*oauth2.Token, error) {
5059
}
5160

5261
func (f *FakeTokenStore) Store(cluster string, token *oauth2.Token) error {
62+
if f.PanicValue != nil {
63+
panic(f.PanicValue)
64+
}
5365
if f.StoreErr != nil {
5466
return f.StoreErr
5567
}
@@ -58,6 +70,9 @@ func (f *FakeTokenStore) Store(cluster string, token *oauth2.Token) error {
5870
}
5971

6072
func (f *FakeTokenStore) Delete(cluster string) error {
73+
if f.PanicValue != nil {
74+
panic(f.PanicValue)
75+
}
6176
if f.DeleteErr != nil {
6277
return f.DeleteErr
6378
}
@@ -92,6 +107,11 @@ func (f *FakeTokenStore) WithDeleteErr(err error) *FakeTokenStore {
92107
return f
93108
}
94109

110+
func (f *FakeTokenStore) WithPanic(value any) *FakeTokenStore {
111+
f.PanicValue = value
112+
return f
113+
}
114+
95115
func NewFakeTokenForSubject(subject string) *oauth2.Token {
96116
now := time.Now()
97117
expiry := now.Add(time.Hour)

0 commit comments

Comments
 (0)