11package cmd
22
33import (
4- "bufio"
4+ "crypto/rand"
5+ "crypto/sha256"
6+ "encoding/base64"
7+ "encoding/json"
58 "fmt"
6- "os"
9+ "net"
10+ "net/http"
11+ "net/url"
12+ "os/exec"
13+ "runtime"
714 "strings"
815
916 "github.com/mldotink/cli/internal/config"
1017 "github.com/spf13/cobra"
1118)
1219
13- var loginGlobal bool
20+ const oauthServerBase = "https://mcp.ml.ink"
1421
1522func init () {
16- loginCmd .Flags ().BoolVar (& loginGlobal , "global" , true , "Save globally (~/.config/ink/config)" )
23+ loginCmd .Flags ().Bool ("global" , true , "Save globally (~/.config/ink/config)" )
24+ loginCmd .Flags ().String ("api-key" , "" , "Authenticate with an API key directly" )
1725 rootCmd .AddCommand (loginCmd )
1826}
1927
2028var loginCmd = & cobra.Command {
21- Use : "login [api-key] " ,
29+ Use : "login" ,
2230 Short : "Authenticate with Ink" ,
23- Long : "Store your API key. Get one at https://ml.ink/account/api-keys " ,
24- Example : `# Interactive prompt
31+ Long : "Log in via browser (default) or provide an API key directly. " ,
32+ Example : `# Browser login (opens browser, recommended)
2533ink login
2634
27- # Pass key directly
28- ink login dk_live_abc123` ,
29- Args : maxArgs ( 1 ) ,
35+ # API key directly
36+ ink login --api-key dk_live_abc123` ,
37+ Args : cobra . NoArgs ,
3038 Run : func (cmd * cobra.Command , args []string ) {
39+ global , _ := cmd .Flags ().GetBool ("global" )
40+ apiKey , _ := cmd .Flags ().GetString ("api-key" )
41+
3142 var key string
32- if len (args ) > 0 {
33- key = strings .TrimSpace (args [0 ])
43+
44+ if apiKey != "" {
45+ if ! strings .HasPrefix (apiKey , "dk_" ) {
46+ fatal ("Invalid API key — keys start with dk_live_ or dk_test_" )
47+ }
48+ key = apiKey
3449 } else {
35- fmt .Print ("Enter API key: " )
36- scanner := bufio .NewScanner (os .Stdin )
37- if scanner .Scan () {
38- key = strings .TrimSpace (scanner .Text ())
50+ k , err := oauthBrowserLogin ()
51+ if err != nil {
52+ fatal (err .Error ())
3953 }
40- }
41-
42- if ! strings .HasPrefix (key , "dk_" ) {
43- fatal ("Invalid API key — keys start with dk_live_ or dk_test_" )
54+ key = k
4455 }
4556
4657 c := & config.Config {APIKey : key }
4758 var err error
48- if loginGlobal {
59+ if global {
4960 err = config .SaveGlobal (c )
5061 } else {
5162 err = config .SaveLocal (c )
@@ -54,10 +65,165 @@ ink login dk_live_abc123`,
5465 fatal (fmt .Sprintf ("Failed to save: %v" , err ))
5566 }
5667
57- if loginGlobal {
58- success ("Saved to ~/.config/ink/config" )
68+ if global {
69+ success ("Logged in — saved to ~/.config/ink/config" )
5970 } else {
60- success ("Saved to .ink (project-local)" )
71+ success ("Logged in — saved to .ink (project-local)" )
6172 }
6273 },
6374}
75+
76+ func oauthBrowserLogin () (string , error ) {
77+ // Generate PKCE code verifier (43-128 URL-safe chars)
78+ verifierBytes := make ([]byte , 32 )
79+ if _ , err := rand .Read (verifierBytes ); err != nil {
80+ return "" , fmt .Errorf ("failed to generate PKCE verifier: %w" , err )
81+ }
82+ codeVerifier := base64 .RawURLEncoding .EncodeToString (verifierBytes )
83+
84+ // code_challenge = BASE64URL(SHA256(code_verifier))
85+ hash := sha256 .Sum256 ([]byte (codeVerifier ))
86+ codeChallenge := base64 .RawURLEncoding .EncodeToString (hash [:])
87+
88+ // Generate state for CSRF protection
89+ stateBytes := make ([]byte , 16 )
90+ rand .Read (stateBytes )
91+ state := base64 .RawURLEncoding .EncodeToString (stateBytes )
92+
93+ // Start local server on random port
94+ listener , err := net .Listen ("tcp" , "127.0.0.1:0" )
95+ if err != nil {
96+ return "" , fmt .Errorf ("failed to start local server: %w" , err )
97+ }
98+ port := listener .Addr ().(* net.TCPAddr ).Port
99+ redirectURI := fmt .Sprintf ("http://127.0.0.1:%d/callback" , port )
100+
101+ // Register client
102+ regBody , _ := json .Marshal (map [string ]any {
103+ "redirect_uris" : []string {redirectURI },
104+ "client_name" : "ink-cli" ,
105+ })
106+ resp , err := http .Post (oauthServerBase + "/oauth/register" , "application/json" , strings .NewReader (string (regBody )))
107+ if err != nil {
108+ listener .Close ()
109+ return "" , fmt .Errorf ("failed to register OAuth client: %w" , err )
110+ }
111+ var regResult struct {
112+ ClientID string `json:"client_id"`
113+ }
114+ json .NewDecoder (resp .Body ).Decode (& regResult )
115+ resp .Body .Close ()
116+ clientID := regResult .ClientID
117+
118+ // Build authorize URL
119+ authorizeURL , _ := url .Parse (oauthServerBase + "/oauth/authorize" )
120+ q := authorizeURL .Query ()
121+ q .Set ("client_id" , clientID )
122+ q .Set ("redirect_uri" , redirectURI )
123+ q .Set ("code_challenge" , codeChallenge )
124+ q .Set ("code_challenge_method" , "S256" )
125+ q .Set ("state" , state )
126+ q .Set ("response_type" , "code" )
127+ authorizeURL .RawQuery = q .Encode ()
128+
129+ // Channel to receive result
130+ result := make (chan oauthResult , 1 )
131+
132+ // Set up callback handler
133+ mux := http .NewServeMux ()
134+ mux .HandleFunc ("/callback" , func (w http.ResponseWriter , r * http.Request ) {
135+ returnedState := r .URL .Query ().Get ("state" )
136+ code := r .URL .Query ().Get ("code" )
137+ errMsg := r .URL .Query ().Get ("error" )
138+
139+ if errMsg != "" {
140+ w .Header ().Set ("Content-Type" , "text/html" )
141+ fmt .Fprintf (w , "<html><body><h2>Login failed</h2><p>%s</p><p>You can close this tab.</p></body></html>" , errMsg )
142+ result <- oauthResult {err : fmt .Errorf ("OAuth error: %s" , errMsg )}
143+ return
144+ }
145+
146+ if returnedState != state {
147+ w .Header ().Set ("Content-Type" , "text/html" )
148+ fmt .Fprint (w , "<html><body><h2>Login failed</h2><p>State mismatch.</p></body></html>" )
149+ result <- oauthResult {err : fmt .Errorf ("OAuth state mismatch" )}
150+ return
151+ }
152+
153+ w .Header ().Set ("Content-Type" , "text/html" )
154+ fmt .Fprint (w , "<html><body><h2>Login successful!</h2><p>You can close this tab and return to the terminal.</p></body></html>" )
155+ result <- oauthResult {code : code }
156+ })
157+
158+ server := & http.Server {Handler : mux }
159+ go server .Serve (listener )
160+
161+ // Open browser
162+ fmt .Println ()
163+ fmt .Println (dim .Render (" Opening browser to log in..." ))
164+ fmt .Println (dim .Render (" If the browser doesn't open, visit:" ))
165+ fmt .Println (dim .Render (" " + authorizeURL .String ()))
166+ fmt .Println ()
167+
168+ openBrowser (authorizeURL .String ())
169+
170+ // Wait for callback
171+ res := <- result
172+ server .Close ()
173+
174+ if res .err != nil {
175+ return "" , res .err
176+ }
177+
178+ // Exchange code for token
179+ tokenData := url.Values {
180+ "grant_type" : {"authorization_code" },
181+ "code" : {res .code },
182+ "code_verifier" : {codeVerifier },
183+ "redirect_uri" : {redirectURI },
184+ "client_id" : {clientID },
185+ }
186+
187+ tokenResp , err := http .PostForm (oauthServerBase + "/oauth/token" , tokenData )
188+ if err != nil {
189+ return "" , fmt .Errorf ("failed to exchange code for token: %w" , err )
190+ }
191+ defer tokenResp .Body .Close ()
192+
193+ var tokenResult struct {
194+ AccessToken string `json:"access_token"`
195+ Error string `json:"error"`
196+ Description string `json:"error_description"`
197+ }
198+ json .NewDecoder (tokenResp .Body ).Decode (& tokenResult )
199+
200+ if tokenResult .Error != "" {
201+ return "" , fmt .Errorf ("token exchange failed: %s — %s" , tokenResult .Error , tokenResult .Description )
202+ }
203+
204+ if tokenResult .AccessToken == "" {
205+ return "" , fmt .Errorf ("no access token in response" )
206+ }
207+
208+ return tokenResult .AccessToken , nil
209+ }
210+
211+ type oauthResult struct {
212+ code string
213+ err error
214+ }
215+
216+ func openBrowser (url string ) {
217+ var cmd * exec.Cmd
218+ switch runtime .GOOS {
219+ case "darwin" :
220+ cmd = exec .Command ("open" , url )
221+ case "linux" :
222+ cmd = exec .Command ("xdg-open" , url )
223+ case "windows" :
224+ cmd = exec .Command ("rundll32" , "url.dll,FileProtocolHandler" , url )
225+ }
226+ if cmd != nil {
227+ cmd .Start ()
228+ }
229+ }
0 commit comments