detached db5f449

feat(cli): add auth login command for remote password authentication

commit
db5f4493cc602318bbe6705b7667b3ab9b10c15a
author
bnema · · 2026-01-18 03:17 UTC
parent
4a71618
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +208 -0
M internal/adapters/in/cli/auth.go +118 -0
@@ -13,6 +13,8 @@ import (
1313 "github.com/spf13/viper"
1414 "golang.org/x/term"
1515
16+ "gordon/internal/adapters/in/cli/remote"
17+ "gordon/internal/adapters/in/cli/ui/styles"
1618 "gordon/internal/adapters/out/secrets"
1719 "gordon/internal/adapters/out/tokenstore"
1820 "gordon/internal/app"
@@ -32,6 +34,7 @@ func newAuthCmd() *cobra.Command {
3234 cmd.AddCommand(newAuthTokenCmd())
3335 cmd.AddCommand(newAuthPasswordCmd())
3436 cmd.AddCommand(newAuthInternalCmd())
37+ cmd.AddCommand(newAuthLoginCmd())
3538
3639 return cmd
3740 }
@@ -197,6 +200,121 @@ available while Gordon is running.`,
197200 }
198201 }
199202
203+// newAuthLoginCmd creates the login command for remote authentication.
204+func newAuthLoginCmd() *cobra.Command {
205+ var (
206+ remoteName string
207+ username string
208+ )
209+
210+ cmd := &cobra.Command{
211+ Use: "login",
212+ Short: "Authenticate with a Gordon server",
213+ Long: `Authenticate with a Gordon server using password authentication.
214+
215+This command prompts for your username and password, authenticates with the
216+remote server's /auth/password endpoint, and stores the returned token.
217+
218+The token is stored in your remote configuration and used for subsequent
219+CLI operations.
220+
221+Note: This command only works with servers configured for password authentication.
222+For servers using token-based auth, use 'gordon remote set-token' instead.
223+
224+Examples:
225+ gordon auth login Login using active remote
226+ gordon auth login --remote prod Login to specific remote
227+ gordon auth login --username admin Pre-fill username`,
228+ RunE: func(cmd *cobra.Command, args []string) error {
229+ return runAuthLogin(remoteName, username)
230+ },
231+ }
232+
233+ cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")
234+ cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
235+
236+ return cmd
237+}
238+
239+// runAuthLogin authenticates with a remote Gordon server.
240+func runAuthLogin(remoteName, username string) error {
241+ // Load remotes config
242+ config, err := remote.LoadRemotes("")
243+ if err != nil {
244+ return fmt.Errorf("failed to load remotes: %w", err)
245+ }
246+
247+ // Determine which remote to use
248+ if remoteName == "" {
249+ remoteName = config.Active
250+ }
251+ if remoteName == "" {
252+ return fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'")
253+ }
254+
255+ remoteConfig, ok := config.Remotes[remoteName]
256+ if !ok {
257+ return fmt.Errorf("remote '%s' not found", remoteName)
258+ }
259+
260+ // Prompt for username if not provided
261+ if username == "" {
262+ fmt.Print("Username: ")
263+ reader := bufio.NewReader(os.Stdin)
264+ input, err := reader.ReadString('\n')
265+ if err != nil {
266+ return fmt.Errorf("failed to read username: %w", err)
267+ }
268+ username = strings.TrimSpace(input)
269+ }
270+
271+ if username == "" {
272+ return fmt.Errorf("username cannot be empty")
273+ }
274+
275+ // Prompt for password (hidden input)
276+ fmt.Print("Password: ")
277+ passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
278+ if err != nil {
279+ // Fallback for non-terminal input
280+ reader := bufio.NewReader(os.Stdin)
281+ password, readErr := reader.ReadString('\n')
282+ if readErr != nil {
283+ return fmt.Errorf("failed to read password: %w", readErr)
284+ }
285+ passwordBytes = []byte(strings.TrimSpace(password))
286+ }
287+ fmt.Println() // newline after hidden password input
288+
289+ if len(passwordBytes) == 0 {
290+ return fmt.Errorf("password cannot be empty")
291+ }
292+
293+ // Create remote client
294+ client := remote.NewClient(remoteConfig.URL)
295+
296+ // Authenticate
297+ ctx := context.Background()
298+ fmt.Printf("Authenticating with %s...\n", remoteName)
299+
300+ resp, err := client.Authenticate(ctx, username, string(passwordBytes))
301+ if err != nil {
302+ return fmt.Errorf("authentication failed: %w", err)
303+ }
304+
305+ // Update remote config with new token
306+ if err := remote.UpdateRemoteToken(remoteName, resp.Token); err != nil {
307+ return fmt.Errorf("failed to save token: %w", err)
308+ }
309+
310+ fmt.Println()
311+ fmt.Println(styles.RenderSuccess("Authentication successful!"))
312+ fmt.Printf("Token stored for remote '%s'\n", remoteName)
313+ fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
314+
315+ return nil
316+}
317+
200318 // runShowInternalAuth displays the internal registry credentials.
201319 func runShowInternalAuth() error {
202320 creds, err := app.GetInternalCredentials()
@@ -13,6 +13,8 @@ import (
13 "github.com/spf13/viper"13 "github.com/spf13/viper"
14 "golang.org/x/term"14 "golang.org/x/term"
15 15
16+ "gordon/internal/adapters/in/cli/remote"
17+ "gordon/internal/adapters/in/cli/ui/styles"
16 "gordon/internal/adapters/out/secrets"18 "gordon/internal/adapters/out/secrets"
17 "gordon/internal/adapters/out/tokenstore"19 "gordon/internal/adapters/out/tokenstore"
18 "gordon/internal/app"20 "gordon/internal/app"
@@ -32,6 +34,7 @@ func newAuthCmd() *cobra.Command {
32 cmd.AddCommand(newAuthTokenCmd())34 cmd.AddCommand(newAuthTokenCmd())
33 cmd.AddCommand(newAuthPasswordCmd())35 cmd.AddCommand(newAuthPasswordCmd())
34 cmd.AddCommand(newAuthInternalCmd())36 cmd.AddCommand(newAuthInternalCmd())
37+ cmd.AddCommand(newAuthLoginCmd())
35 38
36 return cmd39 return cmd
37 }40 }
@@ -197,6 +200,121 @@ available while Gordon is running.`,
197 }200 }
198 }201 }
199 202
203+// newAuthLoginCmd creates the login command for remote authentication.
204+func newAuthLoginCmd() *cobra.Command {
205+ var (
206+ remoteName string
207+ username string
208+ )
209+
210+ cmd := &cobra.Command{
211+ Use: "login",
212+ Short: "Authenticate with a Gordon server",
213+ Long: `Authenticate with a Gordon server using password authentication.
214+
215+This command prompts for your username and password, authenticates with the
216+remote server's /auth/password endpoint, and stores the returned token.
217+
218+The token is stored in your remote configuration and used for subsequent
219+CLI operations.
220+
221+Note: This command only works with servers configured for password authentication.
222+For servers using token-based auth, use 'gordon remote set-token' instead.
223+
224+Examples:
225+ gordon auth login Login using active remote
226+ gordon auth login --remote prod Login to specific remote
227+ gordon auth login --username admin Pre-fill username`,
228+ RunE: func(cmd *cobra.Command, args []string) error {
229+ return runAuthLogin(remoteName, username)
230+ },
231+ }
232+
233+ cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")
234+ cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
235+
236+ return cmd
237+}
238+
239+// runAuthLogin authenticates with a remote Gordon server.
240+func runAuthLogin(remoteName, username string) error {
241+ // Load remotes config
242+ config, err := remote.LoadRemotes("")
243+ if err != nil {
244+ return fmt.Errorf("failed to load remotes: %w", err)
245+ }
246+
247+ // Determine which remote to use
248+ if remoteName == "" {
249+ remoteName = config.Active
250+ }
251+ if remoteName == "" {
252+ return fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'")
253+ }
254+
255+ remoteConfig, ok := config.Remotes[remoteName]
256+ if !ok {
257+ return fmt.Errorf("remote '%s' not found", remoteName)
258+ }
259+
260+ // Prompt for username if not provided
261+ if username == "" {
262+ fmt.Print("Username: ")
263+ reader := bufio.NewReader(os.Stdin)
264+ input, err := reader.ReadString('\n')
265+ if err != nil {
266+ return fmt.Errorf("failed to read username: %w", err)
267+ }
268+ username = strings.TrimSpace(input)
269+ }
270+
271+ if username == "" {
272+ return fmt.Errorf("username cannot be empty")
273+ }
274+
275+ // Prompt for password (hidden input)
276+ fmt.Print("Password: ")
277+ passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
278+ if err != nil {
279+ // Fallback for non-terminal input
280+ reader := bufio.NewReader(os.Stdin)
281+ password, readErr := reader.ReadString('\n')
282+ if readErr != nil {
283+ return fmt.Errorf("failed to read password: %w", readErr)
284+ }
285+ passwordBytes = []byte(strings.TrimSpace(password))
286+ }
287+ fmt.Println() // newline after hidden password input
288+
289+ if len(passwordBytes) == 0 {
290+ return fmt.Errorf("password cannot be empty")
291+ }
292+
293+ // Create remote client
294+ client := remote.NewClient(remoteConfig.URL)
295+
296+ // Authenticate
297+ ctx := context.Background()
298+ fmt.Printf("Authenticating with %s...\n", remoteName)
299+
300+ resp, err := client.Authenticate(ctx, username, string(passwordBytes))
301+ if err != nil {
302+ return fmt.Errorf("authentication failed: %w", err)
303+ }
304+
305+ // Update remote config with new token
306+ if err := remote.UpdateRemoteToken(remoteName, resp.Token); err != nil {
307+ return fmt.Errorf("failed to save token: %w", err)
308+ }
309+
310+ fmt.Println()
311+ fmt.Println(styles.RenderSuccess("Authentication successful!"))
312+ fmt.Printf("Token stored for remote '%s'\n", remoteName)
313+ fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
314+
315+ return nil
316+}
317+
200 // runShowInternalAuth displays the internal registry credentials.318 // runShowInternalAuth displays the internal registry credentials.
201 func runShowInternalAuth() error {319 func runShowInternalAuth() error {
202 creds, err := app.GetInternalCredentials()320 creds, err := app.GetInternalCredentials()
A internal/adapters/in/cli/remote/auth.go +90 -0
@@ -0,0 +1,90 @@
1+package remote
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+)
11+
12+// PasswordRequest represents the request body for POST /auth/password.
13+type PasswordRequest struct {
14+ Username string `json:"username"`
15+ Password string `json:"password"`
16+}
17+
18+// PasswordResponse represents the response from POST /auth/password.
19+type PasswordResponse struct {
20+ Token string `json:"token"`
21+ ExpiresIn int `json:"expires_in"`
22+ IssuedAt string `json:"issued_at"`
23+}
24+
25+// Authenticate calls POST /auth/password and returns a token.
26+// This method does NOT require an existing token since it's used to obtain one.
27+func (c *Client) Authenticate(ctx context.Context, username, password string) (*PasswordResponse, error) {
28+ url := c.baseURL + "/auth/password"
29+
30+ reqBody := PasswordRequest{
31+ Username: username,
32+ Password: password,
33+ }
34+
35+ jsonBody, err := json.Marshal(reqBody)
36+ if err != nil {
37+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
38+ }
39+
40+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
41+ if err != nil {
42+ return nil, fmt.Errorf("failed to create request: %w", err)
43+ }
44+
45+ req.Header.Set("Content-Type", "application/json")
46+ req.Header.Set("Accept", "application/json")
47+
48+ resp, err := c.httpClient.Do(req)
49+ if err != nil {
50+ return nil, fmt.Errorf("failed to connect: %w", err)
51+ }
52+ defer resp.Body.Close()
53+
54+ if resp.StatusCode >= 400 {
55+ body, _ := io.ReadAll(resp.Body)
56+ var errResp struct {
57+ Error string `json:"error"`
58+ }
59+ if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
60+ return nil, fmt.Errorf("%s: %s", resp.Status, errResp.Error)
61+ }
62+ return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
63+ }
64+
65+ var result PasswordResponse
66+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
67+ return nil, fmt.Errorf("failed to decode response: %w", err)
68+ }
69+
70+ return &result, nil
71+}
72+
73+// UpdateRemoteToken updates the token for a named remote.
74+func UpdateRemoteToken(name, token string) error {
75+ config, err := LoadRemotes("")
76+ if err != nil {
77+ return err
78+ }
79+
80+ remote, ok := config.Remotes[name]
81+ if !ok {
82+ return fmt.Errorf("remote '%s' not found", name)
83+ }
84+
85+ remote.Token = token
86+ remote.TokenEnv = "" // Clear token_env when setting token directly
87+ config.Remotes[name] = remote
88+
89+ return SaveRemotes("", config)
90+}
@@ -0,0 +1,90 @@
1+package remote
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+)
11+
12+// PasswordRequest represents the request body for POST /auth/password.
13+type PasswordRequest struct {
14+ Username string `json:"username"`
15+ Password string `json:"password"`
16+}
17+
18+// PasswordResponse represents the response from POST /auth/password.
19+type PasswordResponse struct {
20+ Token string `json:"token"`
21+ ExpiresIn int `json:"expires_in"`
22+ IssuedAt string `json:"issued_at"`
23+}
24+
25+// Authenticate calls POST /auth/password and returns a token.
26+// This method does NOT require an existing token since it's used to obtain one.
27+func (c *Client) Authenticate(ctx context.Context, username, password string) (*PasswordResponse, error) {
28+ url := c.baseURL + "/auth/password"
29+
30+ reqBody := PasswordRequest{
31+ Username: username,
32+ Password: password,
33+ }
34+
35+ jsonBody, err := json.Marshal(reqBody)
36+ if err != nil {
37+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
38+ }
39+
40+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
41+ if err != nil {
42+ return nil, fmt.Errorf("failed to create request: %w", err)
43+ }
44+
45+ req.Header.Set("Content-Type", "application/json")
46+ req.Header.Set("Accept", "application/json")
47+
48+ resp, err := c.httpClient.Do(req)
49+ if err != nil {
50+ return nil, fmt.Errorf("failed to connect: %w", err)
51+ }
52+ defer resp.Body.Close()
53+
54+ if resp.StatusCode >= 400 {
55+ body, _ := io.ReadAll(resp.Body)
56+ var errResp struct {
57+ Error string `json:"error"`
58+ }
59+ if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
60+ return nil, fmt.Errorf("%s: %s", resp.Status, errResp.Error)
61+ }
62+ return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
63+ }
64+
65+ var result PasswordResponse
66+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
67+ return nil, fmt.Errorf("failed to decode response: %w", err)
68+ }
69+
70+ return &result, nil
71+}
72+
73+// UpdateRemoteToken updates the token for a named remote.
74+func UpdateRemoteToken(name, token string) error {
75+ config, err := LoadRemotes("")
76+ if err != nil {
77+ return err
78+ }
79+
80+ remote, ok := config.Remotes[name]
81+ if !ok {
82+ return fmt.Errorf("remote '%s' not found", name)
83+ }
84+
85+ remote.Token = token
86+ remote.TokenEnv = "" // Clear token_env when setting token directly
87+ config.Remotes[name] = remote
88+
89+ return SaveRemotes("", config)
90+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close