Skip to content

Commit 174b8d5

Browse files
Reapply "feat: browser-based login with automatic API key creation (#43)"
This reverts commit bd5a15d.
1 parent bd5a15d commit 174b8d5

File tree

3 files changed

+333
-18
lines changed

3 files changed

+333
-18
lines changed

cmd/login.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,23 @@ import (
77
)
88

99
func init() {
10-
rootCmd.AddCommand(&cobra.Command{
10+
var token string
11+
12+
c := &cobra.Command{
1113
Use: "login",
1214
Short: "Authenticate with your Supermodel account",
13-
Long: `Prompts for an API key and saves it to ~/.supermodel/config.yaml.
15+
Long: `Opens your browser to create an API key and automatically saves it.
1416
15-
Get a key at https://supermodeltools.com/dashboard`,
17+
For CI or headless environments, pass the key directly:
18+
supermodel login --token smsk_live_...`,
1619
RunE: func(cmd *cobra.Command, _ []string) error {
20+
if token != "" {
21+
return auth.LoginWithToken(token)
22+
}
1723
return auth.Login(cmd.Context())
1824
},
19-
})
25+
}
26+
27+
c.Flags().StringVar(&token, "token", "", "API key for non-interactive login (CI)")
28+
rootCmd.AddCommand(c)
2029
}

internal/auth/handler.go

Lines changed: 135 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,122 @@ package auth
33
import (
44
"bufio"
55
"context"
6+
"crypto/rand"
7+
"encoding/hex"
68
"fmt"
9+
"net"
10+
"net/http"
711
"os"
12+
"os/exec"
13+
"runtime"
814
"strings"
915
"syscall"
16+
"time"
1017

1118
"golang.org/x/term"
1219

1320
"github.com/supermodeltools/cli/internal/config"
1421
"github.com/supermodeltools/cli/internal/ui"
1522
)
1623

17-
// Login prompts the user for an API key and saves it to the config file.
18-
// Input is read without echo when a terminal is attached.
19-
func Login(_ context.Context) error {
20-
fmt.Println("Get your API key at https://supermodeltools.com/dashboard")
21-
fmt.Print("Paste your API key: ")
24+
const dashboardBase = "https://dashboard.supermodeltools.com"
2225

23-
key, err := readSecret()
26+
// Login runs the browser-based login flow. Opens the dashboard to create an
27+
// API key, receives it via localhost callback, validates, and saves it.
28+
// Falls back to manual paste if the browser flow fails.
29+
func Login(ctx context.Context) error {
30+
cfg, err := config.Load()
2431
if err != nil {
25-
return fmt.Errorf("read input: %w", err)
32+
return err
2633
}
27-
key = strings.TrimSpace(key)
28-
if key == "" {
29-
return fmt.Errorf("API key cannot be empty")
34+
35+
// Start localhost server on a random port.
36+
listener, err := net.Listen("tcp", "127.0.0.1:0")
37+
if err != nil {
38+
fmt.Fprintln(os.Stderr, "Could not start local server — falling back to manual login.")
39+
return loginManual(cfg)
40+
}
41+
port := listener.Addr().(*net.TCPAddr).Port
42+
state := randomState()
43+
44+
// Channel to receive the API key from the callback.
45+
keyCh := make(chan string, 1)
46+
errCh := make(chan error, 1)
47+
48+
mux := http.NewServeMux()
49+
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
50+
if r.URL.Query().Get("state") != state {
51+
http.Error(w, "Invalid state parameter", http.StatusBadRequest)
52+
return
53+
}
54+
key := r.URL.Query().Get("key")
55+
if key == "" {
56+
http.Error(w, "Missing key", http.StatusBadRequest)
57+
return
58+
}
59+
w.Header().Set("Content-Type", "text/html")
60+
fmt.Fprint(w, `<!DOCTYPE html><html><body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#0a0a0a;color:#fff"><div style="text-align:center"><h2>&#10003; Authenticated</h2><p style="color:#888">You can close this tab and return to your terminal.</p></div></body></html>`)
61+
keyCh <- key
62+
})
63+
64+
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} //nolint:gosec // localhost-only server
65+
go func() {
66+
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
67+
errCh <- err
68+
}
69+
}()
70+
defer srv.Close()
71+
72+
// Build the dashboard URL and open the browser.
73+
authURL := fmt.Sprintf("%s/cli-auth?port=%d&state=%s", dashboardBase, port, state)
74+
fmt.Println("Opening browser to log in...")
75+
fmt.Printf("If the browser doesn't open, visit:\n %s\n\n", authURL)
76+
77+
if err := openBrowser(authURL); err != nil {
78+
fmt.Fprintln(os.Stderr, "Could not open browser — falling back to manual login.")
79+
srv.Close()
80+
return loginManual(cfg)
3081
}
3182

83+
// Wait for callback or timeout.
84+
fmt.Print("Waiting for authentication...")
85+
select {
86+
case key := <-keyCh:
87+
fmt.Println()
88+
cfg.APIKey = strings.TrimSpace(key)
89+
if err := cfg.Save(); err != nil {
90+
return err
91+
}
92+
ui.Success("Authenticated — key saved to %s", config.Path())
93+
return nil
94+
case err := <-errCh:
95+
fmt.Println()
96+
return fmt.Errorf("local server error: %w", err)
97+
case <-time.After(5 * time.Minute):
98+
fmt.Println()
99+
fmt.Fprintln(os.Stderr, "Timed out waiting for browser login — falling back to manual login.")
100+
srv.Close()
101+
return loginManual(cfg)
102+
case <-ctx.Done():
103+
fmt.Println()
104+
return ctx.Err()
105+
}
106+
}
107+
108+
// LoginWithToken saves an API key directly (for CI/headless use).
109+
func LoginWithToken(token string) error {
110+
token = strings.TrimSpace(token)
111+
if token == "" {
112+
return fmt.Errorf("API key cannot be empty")
113+
}
32114
cfg, err := config.Load()
33115
if err != nil {
34116
return err
35117
}
36-
cfg.APIKey = key
118+
cfg.APIKey = token
37119
if err := cfg.Save(); err != nil {
38120
return err
39121
}
40-
41122
ui.Success("Authenticated — key saved to %s", config.Path())
42123
return nil
43124
}
@@ -60,18 +141,58 @@ func Logout(_ context.Context) error {
60141
return nil
61142
}
62143

144+
// loginManual is the fallback paste-based login.
145+
func loginManual(cfg *config.Config) error {
146+
fmt.Println("Get your API key at https://dashboard.supermodeltools.com/api-keys")
147+
fmt.Print("Paste your API key: ")
148+
149+
key, err := readSecret()
150+
if err != nil {
151+
return fmt.Errorf("read input: %w", err)
152+
}
153+
key = strings.TrimSpace(key)
154+
if key == "" {
155+
return fmt.Errorf("API key cannot be empty")
156+
}
157+
158+
cfg.APIKey = key
159+
if err := cfg.Save(); err != nil {
160+
return err
161+
}
162+
ui.Success("Authenticated — key saved to %s", config.Path())
163+
return nil
164+
}
165+
166+
func openBrowser(url string) error {
167+
switch runtime.GOOS {
168+
case "darwin":
169+
return exec.Command("open", url).Start()
170+
case "linux":
171+
return exec.Command("xdg-open", url).Start()
172+
case "windows":
173+
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
174+
default:
175+
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
176+
}
177+
}
178+
179+
func randomState() string {
180+
b := make([]byte, 16)
181+
_, _ = rand.Read(b)
182+
return hex.EncodeToString(b)
183+
}
184+
63185
// readSecret reads a line from stdin, suppressing echo when a TTY is attached.
64186
func readSecret() (string, error) {
65187
fd := int(syscall.Stdin) //nolint:unconvert // syscall.Stdin is uintptr on Windows
66188
if term.IsTerminal(fd) {
67189
b, err := term.ReadPassword(fd)
68-
fmt.Println() // restore newline after hidden input
190+
fmt.Println()
69191
if err != nil {
70192
return "", err
71193
}
72194
return string(b), nil
73195
}
74-
// Non-TTY (pipe, CI): read as plain text
75196
scanner := bufio.NewScanner(os.Stdin)
76197
if scanner.Scan() {
77198
return scanner.Text(), nil

0 commit comments

Comments
 (0)