Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions libs/auth/storage/filestore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}

Expand All @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions libs/auth/storage/filestore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion libs/auth/storage/keyring.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions libs/auth/storage/keyring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions libs/auth/storage/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
18 changes: 18 additions & 0 deletions libs/auth/storage/memory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 18 additions & 1 deletion libs/auth/storage/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions libs/cmdio/io.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions libs/cmdio/io_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package cmdio
import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestAcquireTeaProgramWaitsForRelease(t *testing.T) {
Expand Down Expand Up @@ -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))
}
Loading