@@ -3,41 +3,122 @@ package auth
33import (
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>✓ 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.
64186func 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