rickub/clipublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/rickub/cli.git
git clone ssh://git@rickub.com/rickub/cli.git
auth.go · 299 lines · 9.1 KBGo Blame HistoryRaw
Initial import of the rickub CLI as a standalone public project 1a1d430 Olivier Girardot 9h ago1package cmd
2
3import (
4 "bufio"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "strings"
10 "time"
11
12 "rickub.com/rickub/cli/internal/api"
13 "rickub.com/rickub/cli/internal/config"
14
15 "github.com/spf13/cobra"
16)
17
18var (
19 authLoginHost string
20 authLoginToken string
21 authLoginWithToken bool
22 authLoginScope string
23 authLoginNoBrowser bool
24)
25
26func init() {
27 authCmd := &cobra.Command{
28 Use: "auth",
29 Short: "Authenticate rickub with the website or a personal access token",
30 }
31
32 loginCmd := &cobra.Command{
33 Use: "login",
34 Short: "Log in to a rickub host",
35 Long: `Store a rickub host and personal access token in ~/.config/rickub/config.yaml.
36
37By default this runs the browser (device) flow: it prints a code and a URL,
38waits for you to approve the sign-in while logged in to the website on any
39device, and stores the token the server mints — nothing is copy-pasted.
40
41 rickub auth login # browser flow
42 rickub auth login --host https://dev.rickub.com
43
44The token is stored under the host it was verified against and is only ever
45sent back to that host, so a later --host or RICKUB_HOST pointing elsewhere
46cannot leak it. Log in once per host you use.
47
48To use an existing personal access token instead, pipe it via --with-token:
49
50 echo $PAT | rickub auth login --with-token --host http://localhost:3000
51
52There is also --token, but arguments are visible to other processes (ps) and
53land in shell history, so prefer --with-token or the RICKUB_TOKEN env var.`,
54 Args: cobra.NoArgs,
55 RunE: runAuthLogin,
56 }
57 loginCmd.Flags().StringVar(&authLoginHost, "host", "", "API host to log in to (default https://rickub.com)")
58 loginCmd.Flags().StringVar(&authLoginToken, "token", "", "personal access token (skips the prompt)")
59 loginCmd.Flags().BoolVar(&authLoginWithToken, "with-token", false, "read the token from stdin")
60 loginCmd.Flags().StringVar(&authLoginScope, "scope", "all", "requested token scope: all | read")
61 loginCmd.Flags().BoolVar(&authLoginNoBrowser, "no-browser", false, "print the URL instead of opening a browser")
62
63 statusCmd := &cobra.Command{
64 Use: "status",
65 Short: "Show the active host and verify the stored token",
66 Args: cobra.NoArgs,
67 RunE: runAuthStatus,
68 }
69
70 logoutCmd := &cobra.Command{
71 Use: "logout",
72 Short: "Remove the stored token",
73 Args: cobra.NoArgs,
74 RunE: runAuthLogout,
75 }
76
77 authCmd.AddCommand(loginCmd, statusCmd, logoutCmd)
78 rootCmd.AddCommand(authCmd)
79}
80
81func runAuthLogin(cmd *cobra.Command, _ []string) error {
82 cfg, err := loadConfig()
83 if err != nil {
84 return err
85 }
86
87 // Resolve host: --host on login, else the global --host, else prompt, else default.
88 host := authLoginHost
89 if host == "" {
90 host = flagHost
91 }
92
93 token := authLoginToken
94
95 switch {
96 case authLoginWithToken:
97 // Read the token from stdin (trimmed).
98 b, err := io.ReadAll(cmd.InOrStdin())
99 if err != nil {
100 return fmt.Errorf("read token from stdin: %w", err)
101 }
102 token = strings.TrimSpace(string(b))
103 case token == "":
104 // Browser (device) flow: approve on the website, nothing copy-pasted.
105 if host == "" {
106 host = prompt(cmd, fmt.Sprintf("rickub host [%s]: ", config.DefaultHost))
107 if host == "" {
108 host = config.DefaultHost
109 }
110 }
111 host = config.NormalizeHost(host)
112 token, err = loginViaBrowser(cmd, host)
113 if err != nil {
114 return err
115 }
116 }
117
118 token = strings.TrimSpace(token)
119 if token == "" {
120 return fmt.Errorf("a token is required")
121 }
122 if host == "" {
123 host = config.DefaultHost
124 }
125 host = config.NormalizeHost(host)
126 config.WarnIfInsecure(cmd.ErrOrStderr(), host)
127
128 // Verify the token before persisting it.
129 client := api.New(host, token)
130 user, err := client.GetUser(cmd.Context())
131 if err != nil {
132 return fmt.Errorf("token verification failed against %s: %w", host, err)
133 }
134
135 // Store the token under the host it was just verified against, so it is
136 // never sent anywhere else.
137 cfg.SetToken(host, token)
138 if err := cfg.Save(); err != nil {
139 return err
140 }
141
142 path, _ := config.Path()
143 fmt.Fprintf(cmd.OutOrStdout(), "Logged in to %s as %s (config: %s)\n", host, user.Handle, path)
144 return nil
145}
146
147func runAuthStatus(cmd *cobra.Command, _ []string) error {
148 cfg, err := loadConfig()
149 if err != nil {
150 return err
151 }
152 host := config.ResolveHost(flagHost, cfg)
153 token := config.ResolveToken(flagToken, cfg, host)
154 out := cmd.OutOrStdout()
155 fmt.Fprintf(out, "Host: %s\n", host)
156 if token == "" {
157 if cfg.Host != "" && cfg.TokenFor(cfg.Host) != "" {
158 fmt.Fprintf(out, "No token stored for this host (the stored token belongs to %s).\n", cfg.Host)
159 fmt.Fprintf(out, "Run `rickub auth login --host %s`.\n", host)
160 } else {
161 fmt.Fprintln(out, "Not logged in (no token). Run `rickub auth login`.")
162 }
163 return fmt.Errorf("not logged in")
164 }
165 switch {
166 case flagToken != "":
167 fmt.Fprintln(out, "Token source: --token flag")
168 case os.Getenv(config.EnvToken) != "":
169 fmt.Fprintf(out, "Token source: %s\n", config.EnvToken)
170 default:
171 fmt.Fprintf(out, "Token source: config file (bound to %s)\n", host)
172 }
173 config.WarnIfInsecure(cmd.ErrOrStderr(), host)
174 client := api.New(host, token)
175 user, err := client.GetUser(cmd.Context())
176 if err != nil {
177 fmt.Fprintf(out, "Token: %s (invalid)\n", redact(token))
178 return fmt.Errorf("token check failed: %w", err)
179 }
180 fmt.Fprintf(out, "Token: %s (valid)\n", redact(token))
181 fmt.Fprintf(out, "Logged in as: %s", user.Handle)
182 if user.DisplayName != "" {
183 fmt.Fprintf(out, " (%s)", user.DisplayName)
184 }
185 fmt.Fprintln(out)
186 return nil
187}
188
189func runAuthLogout(cmd *cobra.Command, _ []string) error {
190 cfg, err := loadConfig()
191 if err != nil {
192 return err
193 }
194 // Log out of the host currently in effect, not every host at once.
195 host := config.ResolveHost(flagHost, cfg)
196 if !cfg.ClearToken(host) {
197 fmt.Fprintf(cmd.OutOrStdout(), "No stored token for %s to remove.\n", host)
198 return nil
199 }
200 if err := cfg.Save(); err != nil {
201 return err
202 }
203 fmt.Fprintf(cmd.OutOrStdout(), "Logged out of %s (token removed).\n", host)
204 return nil
205}
206
207// loginViaBrowser runs the device flow against host: it starts a login request,
208// shows the approval URL + code (optionally opening a browser), and polls until
209// the user approves, denies, or the code expires. It returns the minted token.
210func loginViaBrowser(cmd *cobra.Command, host string) (string, error) {
211 if authLoginScope != "read" && authLoginScope != "all" {
212 return "", fmt.Errorf("invalid --scope %q (all or read)", authLoginScope)
213 }
214 clientName := "rickub CLI"
215 if hn, err := os.Hostname(); err == nil && hn != "" {
216 clientName += " on " + hn
217 }
218 client := api.New(host, "")
219 start, err := client.StartDeviceLogin(cmd.Context(), authLoginScope, clientName)
220 if err != nil {
221 return "", fmt.Errorf("start device login against %s: %w", host, err)
222 }
223
224 out := cmd.OutOrStdout()
225 fmt.Fprintln(out)
226 fmt.Fprintln(out, " Sign in with your rickub account.")
227 fmt.Fprintf(out, " Open %s and enter code:\n", start.VerificationURL)
228 fmt.Fprintf(out, "\n %s\n\n", start.UserCode)
229 if !authLoginNoBrowser {
230 // The URL comes from the server: never hand an arbitrary scheme to the
231 // platform opener, which would happily launch a registered handler for
232 // it. Anything but http/https is printed for the user to judge.
233 if err := checkBrowserURL(start.VerificationURIComplete); err != nil {
234 fmt.Fprintf(out, " Not opening a browser: %v\n", err)
235 fmt.Fprintf(out, " Open this URL yourself if you trust it: %s\n", start.VerificationURIComplete)
236 } else if err := openBrowser(start.VerificationURIComplete); err == nil {
237 fmt.Fprintln(out, " Opening your browser…")
238 }
239 }
240
241 interval := time.Duration(start.Interval) * time.Second
242 if interval < time.Second {
243 interval = time.Second
244 }
245 deadline := time.Now().Add(time.Duration(start.ExpiresIn) * time.Second)
246 ticker := time.NewTicker(interval)
247 defer ticker.Stop()
248 fmt.Fprintf(out, " Waiting for approval (expires in %ds)…\n", start.ExpiresIn)
249 for {
250 select {
251 case <-cmd.Context().Done():
252 return "", cmd.Context().Err()
253 case <-ticker.C:
254 }
255 if time.Now().After(deadline) {
256 return "", fmt.Errorf("the sign-in code expired; run `rickub auth login` again")
257 }
258 tok, err := client.PollDeviceToken(cmd.Context(), start.DeviceCode)
259 if err == nil {
260 fmt.Fprintln(out, " Approved.")
261 return tok.AccessToken, nil
262 }
263 var apiErr *api.APIError
264 if !errors.As(err, &apiErr) {
265 return "", err
266 }
267 switch apiErr.Code {
268 case "authorization_pending":
269 // keep waiting
270 case "slow_down":
271 interval *= 2
272 ticker.Reset(interval)
273 default:
274 // access_denied, expired_token, invalid_grant: all terminal.
275 return "", fmt.Errorf("sign-in failed: %s", apiErr.Message)
276 }
277 }
278}
279
280// prompt writes a message and reads a trimmed line from stdin.
281func prompt(cmd *cobra.Command, msg string) string {
282 fmt.Fprint(cmd.OutOrStdout(), msg)
283 r := bufio.NewReader(cmd.InOrStdin())
284 line, _ := r.ReadString('\n')
285 return strings.TrimSpace(line)
286}
287
288// tokenPrefix is the non-secret marker every rickub PAT starts with; the bytes
289// after it are secret material and are never displayed.
290const tokenPrefix = "rickub_pat_"
291
292// redact masks a token for display. It shows only the non-secret prefix, so the
293// output identifies the kind of credential without leaking any of it.
294func redact(token string) string {
295 if strings.HasPrefix(token, tokenPrefix) {
296 return tokenPrefix + "…"
297 }
298 return "****"
299}