Skip to content
Merged
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
10 changes: 5 additions & 5 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,21 @@ import (

type Auth struct {
tokenStorage AuthTokenStorage
browserLogin LoginProvider
login LoginProvider
sink output.Sink
allowLogin bool
}

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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestGetToken_ReturnsTokenWhenKeyringStoreFails(t *testing.T) {

auth := &Auth{
tokenStorage: mockStorage,
browserLogin: mockLogin,
login: mockLogin,
sink: sink,
allowLogin: true,
}
Expand Down
119 changes: 40 additions & 79 deletions internal/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ package auth
import (
"context"
"fmt"
"net"
"net/http"
"os"

"github.com/localstack/lstk/internal/api"
Expand All @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: we might want to change the type a bit so that we can codify the default option more clearly. But for now this is fine

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed.

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()
}
Expand All @@ -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)
}
Expand Down
17 changes: 14 additions & 3 deletions internal/output/format.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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
}
28 changes: 20 additions & 8 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice that this works so well :)

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
Expand Down
11 changes: 10 additions & 1 deletion internal/ui/components/input_prompt.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package components

import (
"strings"

"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/ui/styles"
)
Expand Down Expand Up @@ -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)
}
Loading