Skip to content

Commit 3177af7

Browse files
remove browser flow as login method
1 parent 4565603 commit 3177af7

9 files changed

Lines changed: 123 additions & 259 deletions

File tree

internal/auth/auth.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,21 @@ import (
1313

1414
type Auth struct {
1515
tokenStorage AuthTokenStorage
16-
browserLogin LoginProvider
16+
login LoginProvider
1717
sink output.Sink
1818
allowLogin bool
1919
}
2020

2121
func New(sink output.Sink, platform api.PlatformAPI, storage AuthTokenStorage, allowLogin bool) *Auth {
2222
return &Auth{
2323
tokenStorage: storage,
24-
browserLogin: newBrowserLogin(sink, platform),
24+
login: newLoginProvider(sink, platform),
2525
sink: sink,
2626
allowLogin: allowLogin,
2727
}
2828
}
2929

30-
// GetToken tries in order: 1) keyring 2) LOCALSTACK_AUTH_TOKEN env var 3) browser login (if allowed)
30+
// GetToken tries in order: 1) keyring 2) LOCALSTACK_AUTH_TOKEN env var 3) device flow login
3131
func (a *Auth) GetToken(ctx context.Context) (string, error) {
3232
if token, err := a.tokenStorage.GetAuthToken(); err == nil && token != "" {
3333
return token, nil
@@ -42,7 +42,7 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) {
4242
}
4343

4444
output.EmitLog(a.sink, "Authentication required. Opening browser...")
45-
token, err := a.browserLogin.Login(ctx)
45+
token, err := a.login.Login(ctx)
4646
if err != nil {
4747
output.EmitWarning(a.sink, "Authentication failed.")
4848
return "", err

internal/auth/auth_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func TestGetToken_ReturnsTokenWhenKeyringStoreFails(t *testing.T) {
3131

3232
auth := &Auth{
3333
tokenStorage: mockStorage,
34-
browserLogin: mockLogin,
34+
login: mockLogin,
3535
sink: sink,
3636
allowLogin: true,
3737
}

internal/auth/login.go

Lines changed: 40 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ package auth
55
import (
66
"context"
77
"fmt"
8-
"net"
9-
"net/http"
108
"os"
119

1210
"github.com/localstack/lstk/internal/api"
@@ -15,106 +13,69 @@ import (
1513
)
1614

1715
const webAppURL = "https://app.localstack.cloud"
18-
const loginCallbackURL = "127.0.0.1:45678"
1916

2017
type LoginProvider interface {
2118
Login(ctx context.Context) (string, error)
2219
}
2320

24-
type browserLogin struct {
21+
type loginProvider struct {
2522
platformClient api.PlatformAPI
2623
sink output.Sink
2724
}
2825

29-
func newBrowserLogin(sink output.Sink, platformClient api.PlatformAPI) *browserLogin {
30-
return &browserLogin{
26+
func newLoginProvider(sink output.Sink, platformClient api.PlatformAPI) *loginProvider {
27+
return &loginProvider{
3128
platformClient: platformClient,
3229
sink: sink,
3330
}
3431
}
3532

36-
func startCallbackServer() (*http.Server, chan string, chan error, error) {
37-
listener, err := net.Listen("tcp", loginCallbackURL)
33+
func (l *loginProvider) Login(ctx context.Context) (string, error) {
34+
authReq, err := l.platformClient.CreateAuthRequest(ctx)
3835
if err != nil {
39-
return nil, nil, nil, fmt.Errorf("failed to start callback server: %w", err)
36+
return "", fmt.Errorf("failed to create auth request: %w", err)
4037
}
4138

42-
tokenCh := make(chan string, 1)
43-
errCh := make(chan error, 1)
39+
authURL := fmt.Sprintf("%s/auth/request/%s", getWebAppURL(), authReq.ID)
40+
output.EmitLog(l.sink, fmt.Sprintf("Visit: %s", authURL))
41+
output.EmitLog(l.sink, fmt.Sprintf("Verification code: %s", authReq.Code))
4442

45-
mux := http.NewServeMux()
46-
mux.HandleFunc("/auth/success", func(w http.ResponseWriter, r *http.Request) {
47-
token := r.URL.Query().Get("token")
48-
if token == "" {
49-
errCh <- fmt.Errorf("no token in callback")
50-
http.Error(w, "No token received", http.StatusBadRequest)
51-
return
52-
}
53-
w.WriteHeader(http.StatusOK)
54-
tokenCh <- token
43+
// Ask whether to open the browser; ENTER or Y accepts (default yes), N skips
44+
browserCh := make(chan output.InputResponse, 1)
45+
output.EmitUserInputRequest(l.sink, output.UserInputRequestEvent{
46+
Prompt: "Open browser now?",
47+
Options: []output.InputOption{{Key: "y", Label: "Y"}, {Key: "n", Label: "n"}},
48+
ResponseCh: browserCh,
5549
})
5650

57-
server := &http.Server{Handler: mux}
58-
go func() {
59-
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
60-
errCh <- fmt.Errorf("callback server error: %w", err)
51+
select {
52+
case resp := <-browserCh:
53+
if resp.Cancelled {
54+
return "", context.Canceled
6155
}
62-
}()
63-
64-
return server, tokenCh, errCh, nil
65-
}
66-
67-
func (b *browserLogin) Login(ctx context.Context) (string, error) {
68-
server, tokenCh, errCh, err := startCallbackServer()
69-
if err != nil {
70-
return "", err
71-
}
72-
defer func() {
73-
if err := server.Shutdown(ctx); err != nil {
74-
output.EmitWarning(b.sink, fmt.Sprintf("failed to shutdown server: %v", err))
56+
if resp.SelectedKey != "n" {
57+
if err := browser.OpenURL(authURL); err != nil {
58+
output.EmitLog(l.sink, fmt.Sprintf("Warning: Failed to open browser: %v", err))
59+
}
7560
}
76-
}()
77-
78-
// Device flow as fallback
79-
authReq, err := b.platformClient.CreateAuthRequest(ctx)
80-
if err != nil {
81-
return "", fmt.Errorf("failed to create auth request: %w", err)
82-
}
83-
84-
deviceURL := fmt.Sprintf("%s/auth/request/%s", getWebAppURL(), authReq.ID)
85-
86-
// Try to open browser
87-
loginURL := fmt.Sprintf("%s/redirect?name=CLI", getWebAppURL())
88-
browserOpened := browser.OpenURL(loginURL) == nil
89-
90-
// Display device flow instructions
91-
if browserOpened {
92-
output.EmitLog(b.sink, fmt.Sprintf("Browser didn't open? Open %s to authorize device.", deviceURL))
93-
} else {
94-
output.EmitLog(b.sink, fmt.Sprintf("Open %s to authorize device.", deviceURL))
61+
case <-ctx.Done():
62+
return "", ctx.Err()
9563
}
96-
output.EmitLog(b.sink, fmt.Sprintf("Verification code: %s", authReq.Code))
9764

98-
// Emit user input request event
99-
responseCh := make(chan output.InputResponse, 1)
100-
output.EmitUserInputRequest(b.sink, output.UserInputRequestEvent{
101-
Prompt: "Waiting for authentication...",
65+
// Wait for the user to complete authentication in the browser
66+
enterCh := make(chan output.InputResponse, 1)
67+
output.EmitUserInputRequest(l.sink, output.UserInputRequestEvent{
68+
Prompt: "Waiting for authentication",
10269
Options: []output.InputOption{{Key: "enter", Label: "Press ENTER when complete"}},
103-
ResponseCh: responseCh,
70+
ResponseCh: enterCh,
10471
})
10572

106-
// Wait for either browser callback, user response, or context cancellation
10773
select {
108-
case token := <-tokenCh:
109-
return token, nil
110-
case err := <-errCh:
111-
return "", err
112-
case resp := <-responseCh:
74+
case resp := <-enterCh:
11375
if resp.Cancelled {
11476
return "", context.Canceled
11577
}
116-
// User pressed ENTER, try device flow
117-
return b.completeDeviceFlow(ctx, authReq)
78+
return l.completeAuth(ctx, authReq)
11879
case <-ctx.Done():
11980
return "", ctx.Err()
12081
}
@@ -128,24 +89,24 @@ func getWebAppURL() string {
12889
return webAppURL
12990
}
13091

131-
func (b *browserLogin) completeDeviceFlow(ctx context.Context, authReq *api.AuthRequest) (string, error) {
132-
output.EmitLog(b.sink, "Checking if auth request is confirmed...")
133-
confirmed, err := b.platformClient.CheckAuthRequestConfirmed(ctx, authReq.ID, authReq.ExchangeToken)
92+
func (l *loginProvider) completeAuth(ctx context.Context, authReq *api.AuthRequest) (string, error) {
93+
output.EmitLog(l.sink, "Checking if auth request is confirmed...")
94+
confirmed, err := l.platformClient.CheckAuthRequestConfirmed(ctx, authReq.ID, authReq.ExchangeToken)
13495
if err != nil {
13596
return "", fmt.Errorf("failed to check auth request: %w", err)
13697
}
13798
if !confirmed {
138-
return "", fmt.Errorf("auth request not confirmed - please enter the code in the browser first")
99+
return "", fmt.Errorf("auth request not confirmed - please complete the authorization in your browser")
139100
}
140-
output.EmitLog(b.sink, "Auth request confirmed, exchanging for token...")
101+
output.EmitLog(l.sink, "Auth request confirmed, exchanging for token...")
141102

142-
bearerToken, err := b.platformClient.ExchangeAuthRequest(ctx, authReq.ID, authReq.ExchangeToken)
103+
bearerToken, err := l.platformClient.ExchangeAuthRequest(ctx, authReq.ID, authReq.ExchangeToken)
143104
if err != nil {
144105
return "", fmt.Errorf("failed to exchange auth request: %w", err)
145106
}
146107

147-
output.EmitLog(b.sink, "Fetching license token...")
148-
licenseToken, err := b.platformClient.GetLicenseToken(ctx, bearerToken)
108+
output.EmitLog(l.sink, "Fetching license token...")
109+
licenseToken, err := l.platformClient.GetLicenseToken(ctx, bearerToken)
149110
if err != nil {
150111
return "", fmt.Errorf("failed to get license token: %w", err)
151112
}

internal/output/format.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package output
22

3-
import "fmt"
3+
import (
4+
"fmt"
5+
"strings"
6+
)
47

58
// FormatEventLine converts an output event into a single display line.
69
func FormatEventLine(event any) (string, bool) {
@@ -53,8 +56,16 @@ func formatProgressLine(e ProgressEvent) (string, bool) {
5356
}
5457

5558
func formatUserInputRequest(e UserInputRequestEvent) string {
56-
if len(e.Options) > 0 {
59+
switch len(e.Options) {
60+
case 0:
61+
return e.Prompt
62+
case 1:
5763
return fmt.Sprintf("%s (%s)", e.Prompt, e.Options[0].Label)
64+
default:
65+
labels := make([]string, len(e.Options))
66+
for i, opt := range e.Options {
67+
labels[i] = opt.Label
68+
}
69+
return fmt.Sprintf("%s [%s]", e.Prompt, strings.Join(labels, "/"))
5870
}
59-
return e.Prompt
6071
}

internal/ui/app.go

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,27 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6262
}
6363
return a, tea.Quit
6464
}
65-
if msg.Type == tea.KeyEnter && a.pendingInput != nil {
66-
selectedKey := ""
67-
if len(a.pendingInput.Options) > 0 {
68-
selectedKey = a.pendingInput.Options[0].Key
65+
if a.pendingInput != nil {
66+
if msg.Type == tea.KeyEnter {
67+
// ENTER selects the first option (default)
68+
selectedKey := ""
69+
if len(a.pendingInput.Options) > 0 {
70+
selectedKey = a.pendingInput.Options[0].Key
71+
}
72+
responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: selectedKey})
73+
a.pendingInput = nil
74+
a.inputPrompt = a.inputPrompt.Hide()
75+
return a, responseCmd
76+
}
77+
// A single character key press selects the matching option
78+
for _, opt := range a.pendingInput.Options {
79+
if msg.String() == opt.Key {
80+
responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: opt.Key})
81+
a.pendingInput = nil
82+
a.inputPrompt = a.inputPrompt.Hide()
83+
return a, responseCmd
84+
}
6985
}
70-
responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: selectedKey})
71-
a.pendingInput = nil
72-
a.inputPrompt = a.inputPrompt.Hide()
73-
return a, responseCmd
7486
}
7587
case output.UserInputRequestEvent:
7688
a.pendingInput = &msg

internal/ui/components/input_prompt.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package components
22

33
import (
4+
"strings"
5+
46
"github.com/localstack/lstk/internal/output"
57
"github.com/localstack/lstk/internal/ui/styles"
68
)
@@ -36,8 +38,15 @@ func (p InputPrompt) View() string {
3638
return ""
3739
}
3840
text := p.prompt
39-
if len(p.options) > 0 {
41+
switch len(p.options) {
42+
case 1:
4043
text += " (" + p.options[0].Label + ")"
44+
default:
45+
labels := make([]string, len(p.options))
46+
for i, opt := range p.options {
47+
labels[i] = opt.Label
48+
}
49+
text += " [" + strings.Join(labels, "/") + "]"
4150
}
4251
return styles.Message.Render(text)
4352
}

0 commit comments

Comments
 (0)