diff --git a/libs/auth/storage/filestore.go b/libs/auth/storage/filestore.go index d7f13d397d3..18586a68ca4 100644 --- a/libs/auth/storage/filestore.go +++ b/libs/auth/storage/filestore.go @@ -51,6 +51,7 @@ const ( // the historical bare-token format, leaving room for additive sibling fields. type fileEntry struct { *oauth2.Token + PendingLogin *PendingLogin `json:"pending_login,omitempty"` } // tokenStoreFile is the format of the token store file. @@ -107,7 +108,7 @@ func (c *fileStore) Put(key string, e Entry) error { if f.Tokens == nil { f.Tokens = map[string]*fileEntry{} } - f.Tokens[key] = &fileEntry{Token: e.Token} + f.Tokens[key] = &fileEntry{Token: e.Token, PendingLogin: e.PendingLogin} return c.write(f) } @@ -123,7 +124,7 @@ func (c *fileStore) Lookup(key string) (Entry, error) { if !ok { return Entry{}, ErrNotFound } - return Entry{Token: fe.Token}, nil + return Entry{Token: fe.Token, PendingLogin: fe.PendingLogin}, nil } // Delete implements the Store interface. Removing a missing key is a no-op. diff --git a/libs/auth/storage/filestore_test.go b/libs/auth/storage/filestore_test.go index d905e304839..68e847cb4a1 100644 --- a/libs/auth/storage/filestore_test.go +++ b/libs/auth/storage/filestore_test.go @@ -36,6 +36,28 @@ func TestStoreAndLookup(t *testing.T) { assert.Equal(t, ErrNotFound, err) } +func TestStoreAndLookupPendingLogin(t *testing.T) { + f := setup(t) + c, err := NewFileStore(t.Context(), WithFileLocation(f)) + require.NoError(t, err) + pending := &PendingLogin{DeviceCode: "device-code", UserCode: "WDJBMJHT"} + require.NoError(t, c.Put("x#device-code", Entry{PendingLogin: pending})) + + got, err := c.Lookup("x#device-code") + require.NoError(t, err) + assert.Nil(t, got.Token) + assert.Equal(t, pending, got.PendingLogin) + + raw, err := os.ReadFile(f) + require.NoError(t, err) + assert.JSONEq(t, `{ + "version": 1, + "tokens": { + "x#device-code": {"pending_login": {"device_code": "device-code", "user_code": "WDJBMJHT"}} + } + }`, string(raw)) +} + func TestNoStoreFileReturnsErrNotConfigured(t *testing.T) { l, err := NewFileStore(t.Context(), WithFileLocation(setup(t))) require.NoError(t, err) diff --git a/libs/auth/storage/keyring.go b/libs/auth/storage/keyring.go index e499c00490c..1beccdca3e5 100644 --- a/libs/auth/storage/keyring.go +++ b/libs/auth/storage/keyring.go @@ -47,7 +47,8 @@ type keyringBackend interface { // checksum, store time, ...) without breaking older CLI versions that read // the same entry. type keyringEntry struct { - Token *oauth2.Token `json:"token"` + Token *oauth2.Token `json:"token"` + PendingLogin *PendingLogin `json:"pending_login,omitempty"` } // zalandoBackend delegates to the process-wide zalando/go-keyring provider. diff --git a/libs/auth/storage/keyring_test.go b/libs/auth/storage/keyring_test.go index 7418bf7c7e8..77a7ced4779 100644 --- a/libs/auth/storage/keyring_test.go +++ b/libs/auth/storage/keyring_test.go @@ -118,6 +118,19 @@ func TestKeyringStore_Lookup_ReturnsStoredToken(t *testing.T) { assert.Equal(t, "Bearer", got.Token.TokenType) } +func TestKeyringStore_Lookup_ReturnsStoredPendingLogin(t *testing.T) { + backend := newFakeBackend() + c := newTestStore(backend) + + want := &PendingLogin{DeviceCode: "device-code", UserCode: "WDJBMJHT"} + require.NoError(t, c.Put("my-profile#device-code", Entry{PendingLogin: want})) + + got, err := c.Lookup("my-profile#device-code") + require.NoError(t, err) + assert.Nil(t, got.Token) + assert.Equal(t, want, got.PendingLogin) +} + func TestKeyringStore_Lookup_MissingReturnsCacheErrNotFound(t *testing.T) { backend := newFakeBackend() c := newTestStore(backend) diff --git a/libs/auth/storage/memory.go b/libs/auth/storage/memory.go index 60918968c31..d639cf5f108 100644 --- a/libs/auth/storage/memory.go +++ b/libs/auth/storage/memory.go @@ -29,6 +29,10 @@ func cloneToken(t *oauth2.Token) *oauth2.Token { func cloneEntry(e Entry) Entry { e.Token = cloneToken(e.Token) + if e.PendingLogin != nil { + pending := *e.PendingLogin + e.PendingLogin = &pending + } return e } diff --git a/libs/auth/storage/memory_test.go b/libs/auth/storage/memory_test.go index abccf029248..7c8f4b6109b 100644 --- a/libs/auth/storage/memory_test.go +++ b/libs/auth/storage/memory_test.go @@ -55,6 +55,24 @@ func TestMemoryStore_PutAndLookupUseCopies(t *testing.T) { } } +func TestMemoryStore_PendingLoginUsesCopies(t *testing.T) { + s := NewMemoryStore() + pending := &PendingLogin{DeviceCode: "device-code", UserCode: "WDJBMJHT"} + if err := s.Put("key1", Entry{PendingLogin: pending}); err != nil { + t.Fatalf("Put: %v", err) + } + + pending.DeviceCode = "mutated-after-store" + + got, err := s.Lookup("key1") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if got.PendingLogin.DeviceCode != "device-code" { + t.Fatalf("DeviceCode after store mutation: want %q, got %q", "device-code", got.PendingLogin.DeviceCode) + } +} + func TestMemoryStore_Delete(t *testing.T) { s := NewMemoryStore() if err := s.Put("key1", Entry{Token: &oauth2.Token{AccessToken: "abc"}}); err != nil { diff --git a/libs/auth/storage/storage.go b/libs/auth/storage/storage.go index 3aad20fc645..90253c55dcb 100644 --- a/libs/auth/storage/storage.go +++ b/libs/auth/storage/storage.go @@ -24,8 +24,25 @@ var ErrNotFound = errors.New("token not found") // without changing the Store interface. Backends persist it verbatim and never // interpret its contents. type Entry struct { - // Token is the cached OAuth token. Always set for stored entries. + // Token is the cached OAuth token. Set for every stored entry except a + // pending login. Token *oauth2.Token + + // PendingLogin is set instead of Token for a login that one CLI + // invocation started and a later one completes. + PendingLogin *PendingLogin +} + +// PendingLogin is a device code login that is waiting for the user's +// confirmation code. Keeping the device code in the store means it never +// leaves the machine that started the login. +type PendingLogin struct { + // DeviceCode is exchanged together with the confirmation code for a token. + DeviceCode string `json:"device_code"` + + // UserCode is the code the user enters in the browser. It identifies the + // login when a newer one replaced it under the same key. + UserCode string `json:"user_code"` } // Store is the CLI's token-storage abstraction: a key/value store with no diff --git a/libs/cmdio/io.go b/libs/cmdio/io.go index 2b9b395ce13..7f0dd853e8c 100644 --- a/libs/cmdio/io.go +++ b/libs/cmdio/io.go @@ -83,6 +83,15 @@ func IsPromptSupported(ctx context.Context) bool { return c.capabilities.SupportsPrompt() } +// IsInputTTY reports whether stdin is a terminal, so that a user can type +// input into the command. Unlike IsPromptSupported, it does not depend on +// stderr, NO_COLOR, TERM=dumb or Git Bash, which only matter for rendering +// prompts. +func IsInputTTY(ctx context.Context) bool { + c := fromContext(ctx) + return c.capabilities.stdinIsTTY +} + // IsPagerSupported reports whether stdin, stdout, and stderr are all interactive // terminals. This is the requirement for a full-screen or navigable output // program: unlike IsPromptSupported it also checks stdout, so it returns false diff --git a/libs/cmdio/io_test.go b/libs/cmdio/io_test.go index 23872d5f95c..7c5aad9a6a6 100644 --- a/libs/cmdio/io_test.go +++ b/libs/cmdio/io_test.go @@ -3,6 +3,8 @@ package cmdio import ( "testing" "time" + + "github.com/stretchr/testify/assert" ) func TestAcquireTeaProgramWaitsForRelease(t *testing.T) { @@ -34,3 +36,14 @@ func TestAcquireTeaProgramWaitsForRelease(t *testing.T) { c.releaseTeaProgram() } + +func TestIsInputTTY(t *testing.T) { + // Prompts are not supported without stderr as a terminal and with color, + // but a user can still type into stdin. + ctx := InContext(t.Context(), &cmdIO{capabilities: Capabilities{stdinIsTTY: true}}) + assert.True(t, IsInputTTY(ctx)) + assert.False(t, IsPromptSupported(ctx)) + + ctx = InContext(t.Context(), &cmdIO{capabilities: Capabilities{stderrIsTTY: true, color: true}}) + assert.False(t, IsInputTTY(ctx)) +}