diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b5eaeb2..3c957ee 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -13,7 +13,7 @@ import ( type Auth struct { tokenStorage AuthTokenStorage - browserLogin LoginProvider + login LoginProvider sink output.Sink allowLogin bool } @@ -21,13 +21,13 @@ type Auth struct { func New(sink output.Sink, platform api.PlatformAPI, storage AuthTokenStorage, allowLogin bool) *Auth { return &Auth{ tokenStorage: storage, - browserLogin: newBrowserLogin(sink, platform), + login: newLoginProvider(sink, platform), sink: sink, allowLogin: allowLogin, } } -// GetToken tries in order: 1) keyring 2) LOCALSTACK_AUTH_TOKEN env var 3) browser login (if allowed) +// GetToken tries in order: 1) keyring 2) LOCALSTACK_AUTH_TOKEN env var 3) device flow login func (a *Auth) GetToken(ctx context.Context) (string, error) { if token, err := a.tokenStorage.GetAuthToken(); err == nil && token != "" { return token, nil @@ -41,8 +41,8 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) { return "", fmt.Errorf("authentication required: set LOCALSTACK_AUTH_TOKEN or run in interactive mode") } - output.EmitLog(a.sink, "Authentication required. Opening browser...") - token, err := a.browserLogin.Login(ctx) + output.EmitLog(a.sink, "No existing credentials found. Please log in:") + token, err := a.login.Login(ctx) if err != nil { output.EmitWarning(a.sink, "Authentication failed.") return "", err diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 4f57d45..387660d 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -31,7 +31,7 @@ func TestGetToken_ReturnsTokenWhenKeyringStoreFails(t *testing.T) { auth := &Auth{ tokenStorage: mockStorage, - browserLogin: mockLogin, + login: mockLogin, sink: sink, allowLogin: true, } diff --git a/internal/auth/login.go b/internal/auth/login.go index 64e3c4f..8d7fa55 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -5,8 +5,6 @@ package auth import ( "context" "fmt" - "net" - "net/http" "os" "github.com/localstack/lstk/internal/api" @@ -15,106 +13,69 @@ import ( ) const webAppURL = "https://app.localstack.cloud" -const loginCallbackURL = "127.0.0.1:45678" type LoginProvider interface { Login(ctx context.Context) (string, error) } -type browserLogin struct { +type loginProvider struct { platformClient api.PlatformAPI sink output.Sink } -func newBrowserLogin(sink output.Sink, platformClient api.PlatformAPI) *browserLogin { - return &browserLogin{ +func newLoginProvider(sink output.Sink, platformClient api.PlatformAPI) *loginProvider { + return &loginProvider{ platformClient: platformClient, sink: sink, } } -func startCallbackServer() (*http.Server, chan string, chan error, error) { - listener, err := net.Listen("tcp", loginCallbackURL) +func (l *loginProvider) Login(ctx context.Context) (string, error) { + authReq, err := l.platformClient.CreateAuthRequest(ctx) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to start callback server: %w", err) + return "", fmt.Errorf("failed to create auth request: %w", err) } - tokenCh := make(chan string, 1) - errCh := make(chan error, 1) + authURL := fmt.Sprintf("%s/auth/request/%s", getWebAppURL(), authReq.ID) + output.EmitLog(l.sink, fmt.Sprintf("Visit: %s", authURL)) + output.EmitLog(l.sink, fmt.Sprintf("Verification code: %s", authReq.Code)) - mux := http.NewServeMux() - mux.HandleFunc("/auth/success", func(w http.ResponseWriter, r *http.Request) { - token := r.URL.Query().Get("token") - if token == "" { - errCh <- fmt.Errorf("no token in callback") - http.Error(w, "No token received", http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusOK) - tokenCh <- token + // Ask whether to open the browser; ENTER or Y accepts (default yes), N skips + browserCh := make(chan output.InputResponse, 1) + output.EmitUserInputRequest(l.sink, output.UserInputRequestEvent{ + Prompt: "Open browser now?", + Options: []output.InputOption{{Key: "y", Label: "Y"}, {Key: "n", Label: "n"}}, + ResponseCh: browserCh, }) - server := &http.Server{Handler: mux} - go func() { - if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { - errCh <- fmt.Errorf("callback server error: %w", err) + select { + case resp := <-browserCh: + if resp.Cancelled { + return "", context.Canceled } - }() - - return server, tokenCh, errCh, nil -} - -func (b *browserLogin) Login(ctx context.Context) (string, error) { - server, tokenCh, errCh, err := startCallbackServer() - if err != nil { - return "", err - } - defer func() { - if err := server.Shutdown(ctx); err != nil { - output.EmitWarning(b.sink, fmt.Sprintf("failed to shutdown server: %v", err)) + if resp.SelectedKey != "n" { + if err := browser.OpenURL(authURL); err != nil { + output.EmitLog(l.sink, fmt.Sprintf("Warning: Failed to open browser: %v", err)) + } } - }() - - // Device flow as fallback - authReq, err := b.platformClient.CreateAuthRequest(ctx) - if err != nil { - return "", fmt.Errorf("failed to create auth request: %w", err) - } - - deviceURL := fmt.Sprintf("%s/auth/request/%s", getWebAppURL(), authReq.ID) - - // Try to open browser - loginURL := fmt.Sprintf("%s/redirect?name=CLI", getWebAppURL()) - browserOpened := browser.OpenURL(loginURL) == nil - - // Display device flow instructions - if browserOpened { - output.EmitLog(b.sink, fmt.Sprintf("Browser didn't open? Open %s to authorize device.", deviceURL)) - } else { - output.EmitLog(b.sink, fmt.Sprintf("Open %s to authorize device.", deviceURL)) + case <-ctx.Done(): + return "", ctx.Err() } - output.EmitLog(b.sink, fmt.Sprintf("Verification code: %s", authReq.Code)) - // Emit user input request event - responseCh := make(chan output.InputResponse, 1) - output.EmitUserInputRequest(b.sink, output.UserInputRequestEvent{ - Prompt: "Waiting for authentication...", + // Wait for the user to complete authentication in the browser + enterCh := make(chan output.InputResponse, 1) + output.EmitUserInputRequest(l.sink, output.UserInputRequestEvent{ + Prompt: "Waiting for authentication", Options: []output.InputOption{{Key: "enter", Label: "Press ENTER when complete"}}, - ResponseCh: responseCh, + ResponseCh: enterCh, }) - // Wait for either browser callback, user response, or context cancellation select { - case token := <-tokenCh: - return token, nil - case err := <-errCh: - return "", err - case resp := <-responseCh: + case resp := <-enterCh: if resp.Cancelled { return "", context.Canceled } - // User pressed ENTER, try device flow - return b.completeDeviceFlow(ctx, authReq) + return l.completeAuth(ctx, authReq) case <-ctx.Done(): return "", ctx.Err() } @@ -128,24 +89,24 @@ func getWebAppURL() string { return webAppURL } -func (b *browserLogin) completeDeviceFlow(ctx context.Context, authReq *api.AuthRequest) (string, error) { - output.EmitLog(b.sink, "Checking if auth request is confirmed...") - confirmed, err := b.platformClient.CheckAuthRequestConfirmed(ctx, authReq.ID, authReq.ExchangeToken) +func (l *loginProvider) completeAuth(ctx context.Context, authReq *api.AuthRequest) (string, error) { + output.EmitLog(l.sink, "Checking if auth request is confirmed...") + confirmed, err := l.platformClient.CheckAuthRequestConfirmed(ctx, authReq.ID, authReq.ExchangeToken) if err != nil { return "", fmt.Errorf("failed to check auth request: %w", err) } if !confirmed { - return "", fmt.Errorf("auth request not confirmed - please enter the code in the browser first") + return "", fmt.Errorf("auth request not confirmed - please complete the authentication in your browser") } - output.EmitLog(b.sink, "Auth request confirmed, exchanging for token...") + output.EmitLog(l.sink, "Auth request confirmed, exchanging for token...") - bearerToken, err := b.platformClient.ExchangeAuthRequest(ctx, authReq.ID, authReq.ExchangeToken) + bearerToken, err := l.platformClient.ExchangeAuthRequest(ctx, authReq.ID, authReq.ExchangeToken) if err != nil { return "", fmt.Errorf("failed to exchange auth request: %w", err) } - output.EmitLog(b.sink, "Fetching license token...") - licenseToken, err := b.platformClient.GetLicenseToken(ctx, bearerToken) + output.EmitLog(l.sink, "Fetching license token...") + licenseToken, err := l.platformClient.GetLicenseToken(ctx, bearerToken) if err != nil { return "", fmt.Errorf("failed to get license token: %w", err) } diff --git a/internal/output/format.go b/internal/output/format.go index f10cdfa..f46b723 100644 --- a/internal/output/format.go +++ b/internal/output/format.go @@ -1,6 +1,9 @@ package output -import "fmt" +import ( + "fmt" + "strings" +) // FormatEventLine converts an output event into a single display line. func FormatEventLine(event any) (string, bool) { @@ -53,8 +56,16 @@ func formatProgressLine(e ProgressEvent) (string, bool) { } func formatUserInputRequest(e UserInputRequestEvent) string { - if len(e.Options) > 0 { + switch len(e.Options) { + case 0: + return e.Prompt + case 1: return fmt.Sprintf("%s (%s)", e.Prompt, e.Options[0].Label) + default: + labels := make([]string, len(e.Options)) + for i, opt := range e.Options { + labels[i] = opt.Label + } + return fmt.Sprintf("%s [%s]", e.Prompt, strings.Join(labels, "/")) } - return e.Prompt } diff --git a/internal/ui/app.go b/internal/ui/app.go index 5c54730..0c6622e 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -62,15 +62,27 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, tea.Quit } - if msg.Type == tea.KeyEnter && a.pendingInput != nil { - selectedKey := "" - if len(a.pendingInput.Options) > 0 { - selectedKey = a.pendingInput.Options[0].Key + if a.pendingInput != nil { + if msg.Type == tea.KeyEnter { + // ENTER selects the first option (default) + selectedKey := "" + if len(a.pendingInput.Options) > 0 { + selectedKey = a.pendingInput.Options[0].Key + } + responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: selectedKey}) + a.pendingInput = nil + a.inputPrompt = a.inputPrompt.Hide() + return a, responseCmd + } + // A single character key press selects the matching option + for _, opt := range a.pendingInput.Options { + if msg.String() == opt.Key { + responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: opt.Key}) + a.pendingInput = nil + a.inputPrompt = a.inputPrompt.Hide() + return a, responseCmd + } } - responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: selectedKey}) - a.pendingInput = nil - a.inputPrompt = a.inputPrompt.Hide() - return a, responseCmd } case output.UserInputRequestEvent: a.pendingInput = &msg diff --git a/internal/ui/components/input_prompt.go b/internal/ui/components/input_prompt.go index 360e0b6..3f2eca8 100644 --- a/internal/ui/components/input_prompt.go +++ b/internal/ui/components/input_prompt.go @@ -1,6 +1,8 @@ package components import ( + "strings" + "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ui/styles" ) @@ -36,8 +38,15 @@ func (p InputPrompt) View() string { return "" } text := p.prompt - if len(p.options) > 0 { + switch len(p.options) { + case 1: text += " (" + p.options[0].Label + ")" + default: + labels := make([]string, len(p.options)) + for i, opt := range p.options { + labels[i] = opt.Label + } + text += " [" + strings.Join(labels, "/") + "]" } return styles.Message.Render(text) } diff --git a/internal/ui/run_login_test.go b/internal/ui/run_login_test.go index 6a709f5..19e73ca 100644 --- a/internal/ui/run_login_test.go +++ b/internal/ui/run_login_test.go @@ -110,7 +110,13 @@ func TestLoginFlow_DeviceFlowSuccess(t *testing.T) { }() teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { - return bytes.Contains(bts, []byte("TEST123")) + return bytes.Contains(bts, []byte("Open browser now?")) + }, teatest.WithDuration(5*time.Second)) + + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("Waiting for authentication")) }, teatest.WithDuration(5*time.Second)) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) @@ -159,7 +165,13 @@ func TestLoginFlow_DeviceFlowFailure_NotConfirmed(t *testing.T) { }() teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { - return bytes.Contains(bts, []byte("TEST123")) + return bytes.Contains(bts, []byte("Open browser now?")) + }, teatest.WithDuration(5*time.Second)) + + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("Waiting for authentication")) }, teatest.WithDuration(5*time.Second)) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) @@ -179,58 +191,3 @@ func TestLoginFlow_DeviceFlowFailure_NotConfirmed(t *testing.T) { assert.Contains(t, out, "Authentication failed") } -func TestLoginFlow_BrowserCallback(t *testing.T) { - mockServer := createMockAPIServer(t, "test-license-token", true) - defer mockServer.Close() - - t.Setenv("LOCALSTACK_API_ENDPOINT", mockServer.URL) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - ctrl := gomock.NewController(t) - mockStorage := auth.NewMockAuthTokenStorage(ctrl) - mockStorage.EXPECT().GetAuthToken().Return("", errors.New("no token")) - mockStorage.EXPECT().SetAuthToken(gomock.Any()).Return(nil) - - tm := teatest.NewTestModel(t, NewApp("test", cancel), teatest.WithInitialTermSize(120, 40)) - sender := testModelSender{tm: tm} - platformClient := api.NewPlatformClient() - - errCh := make(chan error, 1) - go func() { - a := auth.New(output.NewTUISink(sender), platformClient, mockStorage, true) - _, err := a.GetToken(ctx) - errCh <- err - if err != nil && !errors.Is(err, context.Canceled) { - tm.Send(runErrMsg{err: err}) - } else { - tm.Send(runDoneMsg{}) - } - }() - - teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { - return bytes.Contains(bts, []byte("TEST123")) - }, teatest.WithDuration(5*time.Second)) - - var resp *http.Response - require.Eventually(t, func() bool { - var err error - resp, err = http.Get("http://127.0.0.1:45678/auth/success?token=browser-token") - return err == nil - }, 5*time.Second, 100*time.Millisecond, "callback server should be ready") - require.NoError(t, resp.Body.Close()) - - select { - case err := <-errCh: - require.NoError(t, err, "login should succeed via browser callback") - case <-time.After(10 * time.Second): - t.Fatal("timeout waiting for login") - } - - tm.Send(tea.QuitMsg{}) - tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) - - out := readOutput(tm.FinalOutput(t)) - assert.Contains(t, out, "Login successful") -} diff --git a/test/integration/login_browser_flow_test.go b/test/integration/login_browser_flow_test.go deleted file mode 100644 index c4e881e..0000000 --- a/test/integration/login_browser_flow_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package integration_test - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os/exec" - "runtime" - "testing" - "time" - - "github.com/creack/pty" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestBrowserFlowStoresToken(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("PTY not supported on Windows") - } - - cleanup() - t.Cleanup(cleanup) - - // Mock server that handles both auth and license endpoints - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "POST" && r.URL.Path == "/v1/auth/request": - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]string{ - "id": "test-id", - "code": "TEST123", - "exchange_token": "test-exchange", - }) - case r.Method == "POST" && r.URL.Path == "/v1/license/request": - w.WriteHeader(http.StatusOK) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer mockServer.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, binaryPath(), "login") - cmd.Env = append( - envWithout("LOCALSTACK_AUTH_TOKEN"), - "LOCALSTACK_API_ENDPOINT="+mockServer.URL, - ) - - ptmx, err := pty.Start(cmd) - require.NoError(t, err, "failed to start command in PTY") - defer func() { _ = ptmx.Close() }() - - output := &syncBuffer{} - outputCh := make(chan struct{}) - go func() { - _, _ = io.Copy(output, ptmx) - close(outputCh) - }() - - // Wait for verification code to appear (callback server should be ready) - require.Eventually(t, func() bool { - return bytes.Contains(output.Bytes(), []byte("TEST123")) - }, 10*time.Second, 100*time.Millisecond, "verification code should appear") - - // Simulate browser callback with mock token - var resp *http.Response - require.Eventually(t, func() bool { - var err error - resp, err = http.Get("http://127.0.0.1:45678/auth/success?token=mock-token") - return err == nil - }, 5*time.Second, 100*time.Millisecond, "callback server should be ready") - require.NoError(t, resp.Body.Close()) - - // Wait for process to complete - err = cmd.Wait() - <-outputCh - - out := output.String() - - // Login should succeed - require.NoError(t, err, "login should succeed via browser callback: %s", out) - assert.Contains(t, out, "Login successful") - - // Verify token was stored in keyring - storedToken, err := GetAuthTokenFromKeyring() - require.NoError(t, err, "token should be stored in keyring") - assert.Equal(t, "mock-token", storedToken) -} diff --git a/test/integration/login_device_flow_test.go b/test/integration/login_test.go similarity index 81% rename from test/integration/login_device_flow_test.go rename to test/integration/login_test.go index e59fa74..efa18f6 100644 --- a/test/integration/login_device_flow_test.go +++ b/test/integration/login_test.go @@ -104,12 +104,17 @@ func TestDeviceFlowSuccess(t *testing.T) { close(outputCh) }() - // Wait for verification code to appear + // Wait for browser prompt, then press Y to open browser require.Eventually(t, func() bool { - return bytes.Contains(output.Bytes(), []byte("TEST123")) - }, 10*time.Second, 100*time.Millisecond, "verification code should appear") + return bytes.Contains(output.Bytes(), []byte("Open browser now?")) + }, 10*time.Second, 100*time.Millisecond, "browser prompt should appear") + _, err = ptmx.Write([]byte("y")) + require.NoError(t, err) - // Send Enter to continue with device flow + // Wait for ENTER prompt, then press ENTER to confirm auth is complete + require.Eventually(t, func() bool { + return bytes.Contains(output.Bytes(), []byte("Waiting for authentication")) + }, 10*time.Second, 100*time.Millisecond, "waiting prompt should appear") _, err = ptmx.Write([]byte("\r")) require.NoError(t, err) @@ -119,10 +124,9 @@ func TestDeviceFlowSuccess(t *testing.T) { out := output.String() require.NoError(t, err, "login should succeed: %s", out) - // Should show device flow instructions assert.Contains(t, out, "Verification code:") assert.Contains(t, out, "TEST123") - // Should complete device flow successfully + assert.Contains(t, out, "Open browser now?") assert.Contains(t, out, "Checking if auth request is confirmed") assert.Contains(t, out, "Auth request confirmed") assert.Contains(t, out, "Fetching license token") @@ -165,12 +169,17 @@ func TestDeviceFlowFailure_RequestNotConfirmed(t *testing.T) { close(outputCh) }() - // Wait for verification code to appear + // Wait for browser prompt, then press Y to open browser require.Eventually(t, func() bool { - return bytes.Contains(output.Bytes(), []byte("TEST123")) - }, 10*time.Second, 100*time.Millisecond, "verification code should appear") + return bytes.Contains(output.Bytes(), []byte("Open browser now?")) + }, 10*time.Second, 100*time.Millisecond, "browser prompt should appear") + _, err = ptmx.Write([]byte("y")) + require.NoError(t, err) - // Send Enter to continue with device flow + // Wait for ENTER prompt, then press ENTER to confirm auth is complete + require.Eventually(t, func() bool { + return bytes.Contains(output.Bytes(), []byte("Waiting for authentication")) + }, 10*time.Second, 100*time.Millisecond, "waiting prompt should appear") _, err = ptmx.Write([]byte("\r")) require.NoError(t, err) @@ -181,9 +190,8 @@ func TestDeviceFlowFailure_RequestNotConfirmed(t *testing.T) { out := output.String() require.Error(t, err, "expected login to fail when request not confirmed") assert.Contains(t, out, "Verification code:") + assert.Contains(t, out, "Open browser now?") assert.Contains(t, out, "Waiting for authentication") - assert.Contains(t, out, "Press ENTER when complete") - // Should attempt device flow but fail because request not confirmed assert.Contains(t, out, "Checking if auth request is confirmed") assert.Contains(t, out, "auth request not confirmed")