Skip to content

Commit 280df03

Browse files
committed
feat: add secrets management, OAuth login, and --env-file support
- ink secrets (set/import/list/delete/unset) with server-side merge - ink login with OAuth PKCE browser flow (default) and --api-key flag - --env-file flag on deploy/redeploy for secure secret passing
1 parent 6597503 commit 280df03

6 files changed

Lines changed: 852 additions & 41 deletions

File tree

‎cmd/deploy.go‎

Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package cmd
22

33
import (
4+
"bufio"
45
"fmt"
6+
"os"
57
"strings"
68

79
"github.com/Khan/genqlient/graphql"
@@ -36,6 +38,9 @@ func addServiceFlags(cmd *cobra.Command) {
3638
if f.Lookup("env") == nil {
3739
f.StringArray("env", nil, "Environment variable as KEY=VALUE (repeatable)")
3840
}
41+
if f.Lookup("env-file") == nil {
42+
f.StringArray("env-file", nil, "Read env vars from file (repeatable, e.g. .env)")
43+
}
3944
if f.Lookup("build-command") == nil {
4045
f.String("build-command", "", "Custom build command")
4146
}
@@ -63,8 +68,11 @@ var deployCmd = &cobra.Command{
6368
Example: `# Deploy a Node.js app
6469
ink deploy myapp
6570
66-
# Deploy with environment variables
67-
ink deploy myapi --env DATABASE_URL=postgres://... --env SECRET_KEY=abc123
71+
# Deploy with env vars from a file (recommended for secrets)
72+
ink deploy myapi --env-file .env
73+
74+
# Deploy with inline env vars (for non-sensitive values)
75+
ink deploy myapi --env NODE_ENV=production --env PORT=8080
6876
6977
# Deploy with custom resources
7078
ink deploy myapi --memory 2Gi --vcpu 1
@@ -105,8 +113,8 @@ ink redeploy myapi
105113
# Redeploy and update memory
106114
ink redeploy myapi --memory 2Gi
107115
108-
# Redeploy with new env vars
109-
ink redeploy myapi --env DATABASE_URL=postgres://... --env SECRET_KEY=abc123
116+
# Redeploy with env vars from file
117+
ink redeploy myapi --env-file .env
110118
111119
# Redeploy with different build settings
112120
ink redeploy myapi --buildpack dockerfile --dockerfile Dockerfile.prod`,
@@ -186,9 +194,7 @@ func runCreate(cmd *cobra.Command, client graphql.Client, name string) {
186194
input.Regions = []string{region}
187195
}
188196

189-
if envs, _ := cmd.Flags().GetStringArray("env"); len(envs) > 0 {
190-
input.EnvVars = parseEnvVars(envs)
191-
}
197+
input.EnvVars = collectEnvVars(cmd)
192198

193199
result, err := gql.CreateService(ctx(), client, input)
194200
if err != nil {
@@ -264,9 +270,7 @@ func runUpdate(cmd *cobra.Command, client graphql.Client, name string) {
264270
input.Port = &v
265271
}
266272

267-
if envs, _ := cmd.Flags().GetStringArray("env"); len(envs) > 0 {
268-
input.EnvVars = parseEnvVars(envs)
269-
}
273+
input.EnvVars = collectEnvVars(cmd)
270274

271275
result, err := gql.UpdateService(ctx(), client, input)
272276
if err != nil {
@@ -285,12 +289,66 @@ func runUpdate(cmd *cobra.Command, client graphql.Client, name string) {
285289
fmt.Println()
286290
}
287291

288-
func parseEnvVars(envs []string) []gql.EnvVarInput {
292+
func collectEnvVars(cmd *cobra.Command) []gql.EnvVarInput {
293+
vars := make(map[string]string)
294+
295+
// env-file first (lower priority)
296+
if files, _ := cmd.Flags().GetStringArray("env-file"); len(files) > 0 {
297+
for _, f := range files {
298+
parsed, err := parseEnvFile(f)
299+
if err != nil {
300+
fatal(err.Error())
301+
}
302+
for _, v := range parsed {
303+
vars[v.Key] = v.Value
304+
}
305+
}
306+
}
307+
308+
// --env flags override env-file
309+
if envs, _ := cmd.Flags().GetStringArray("env"); len(envs) > 0 {
310+
for _, e := range envs {
311+
if k, v, ok := strings.Cut(e, "="); ok {
312+
vars[k] = v
313+
}
314+
}
315+
}
316+
317+
if len(vars) == 0 {
318+
return nil
319+
}
320+
321+
result := make([]gql.EnvVarInput, 0, len(vars))
322+
for k, v := range vars {
323+
result = append(result, gql.EnvVarInput{Key: k, Value: v})
324+
}
325+
return result
326+
}
327+
328+
func parseEnvFile(path string) ([]gql.EnvVarInput, error) {
329+
f, err := os.Open(path)
330+
if err != nil {
331+
return nil, fmt.Errorf("cannot read env file %s: %w", path, err)
332+
}
333+
defer f.Close()
334+
289335
var vars []gql.EnvVarInput
290-
for _, e := range envs {
291-
if k, v, ok := strings.Cut(e, "="); ok {
292-
vars = append(vars, gql.EnvVarInput{Key: k, Value: v})
336+
scanner := bufio.NewScanner(f)
337+
for scanner.Scan() {
338+
line := strings.TrimSpace(scanner.Text())
339+
if line == "" || strings.HasPrefix(line, "#") {
340+
continue
341+
}
342+
k, v, ok := strings.Cut(line, "=")
343+
if !ok {
344+
continue
345+
}
346+
k = strings.TrimSpace(k)
347+
v = strings.TrimSpace(v)
348+
if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) {
349+
v = v[1 : len(v)-1]
293350
}
351+
vars = append(vars, gql.EnvVarInput{Key: k, Value: v})
294352
}
295-
return vars
353+
return vars, scanner.Err()
296354
}

‎cmd/login.go‎

Lines changed: 190 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,62 @@
11
package cmd
22

33
import (
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

1522
func 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

2028
var 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)
2533
ink 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

Comments
 (0)