| Initial import of the rickub CLI as a standalone public project 1a1d430 Olivier Girardot 9h ago | 1 | package cmd |
| 2 | |
| 3 | import ( |
| 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 | |
| 18 | var ( |
| 19 | authLoginHost string |
| 20 | authLoginToken string |
| 21 | authLoginWithToken bool |
| 22 | authLoginScope string |
| 23 | authLoginNoBrowser bool |
| 24 | ) |
| 25 | |
| 26 | func 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 | |
| 37 | By default this runs the browser (device) flow: it prints a code and a URL, |
| 38 | waits for you to approve the sign-in while logged in to the website on any |
| 39 | device, 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 | |
| 44 | The token is stored under the host it was verified against and is only ever |
| 45 | sent back to that host, so a later --host or RICKUB_HOST pointing elsewhere |
| 46 | cannot leak it. Log in once per host you use. |
| 47 | |
| 48 | To 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 | |
| 52 | There is also --token, but arguments are visible to other processes (ps) and |
| 53 | land 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 | |
| 81 | func 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 | |
| 147 | func 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 | |
| 189 | func 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. |
| 210 | func 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. |
| 281 | func 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. |
| 290 | const 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. |
| 294 | func redact(token string) string { |
| 295 | if strings.HasPrefix(token, tokenPrefix) { |
| 296 | return tokenPrefix + "…" |
| 297 | } |
| 298 | return "****" |
| 299 | } |