package cmd import ( "bufio" "errors" "fmt" "io" "os" "strings" "time" "rickub.com/rickub/cli/internal/api" "rickub.com/rickub/cli/internal/config" "github.com/spf13/cobra" ) var ( authLoginHost string authLoginToken string authLoginWithToken bool authLoginScope string authLoginNoBrowser bool ) func init() { authCmd := &cobra.Command{ Use: "auth", Short: "Authenticate rickub with the website or a personal access token", } loginCmd := &cobra.Command{ Use: "login", Short: "Log in to a rickub host", Long: `Store a rickub host and personal access token in ~/.config/rickub/config.yaml. By default this runs the browser (device) flow: it prints a code and a URL, waits for you to approve the sign-in while logged in to the website on any device, and stores the token the server mints — nothing is copy-pasted. rickub auth login # browser flow rickub auth login --host https://dev.rickub.com The token is stored under the host it was verified against and is only ever sent back to that host, so a later --host or RICKUB_HOST pointing elsewhere cannot leak it. Log in once per host you use. To use an existing personal access token instead, pipe it via --with-token: echo $PAT | rickub auth login --with-token --host http://localhost:3000 There is also --token, but arguments are visible to other processes (ps) and land in shell history, so prefer --with-token or the RICKUB_TOKEN env var.`, Args: cobra.NoArgs, RunE: runAuthLogin, } loginCmd.Flags().StringVar(&authLoginHost, "host", "", "API host to log in to (default https://rickub.com)") loginCmd.Flags().StringVar(&authLoginToken, "token", "", "personal access token (skips the prompt)") loginCmd.Flags().BoolVar(&authLoginWithToken, "with-token", false, "read the token from stdin") loginCmd.Flags().StringVar(&authLoginScope, "scope", "all", "requested token scope: all | read") loginCmd.Flags().BoolVar(&authLoginNoBrowser, "no-browser", false, "print the URL instead of opening a browser") statusCmd := &cobra.Command{ Use: "status", Short: "Show the active host and verify the stored token", Args: cobra.NoArgs, RunE: runAuthStatus, } logoutCmd := &cobra.Command{ Use: "logout", Short: "Remove the stored token", Args: cobra.NoArgs, RunE: runAuthLogout, } authCmd.AddCommand(loginCmd, statusCmd, logoutCmd) rootCmd.AddCommand(authCmd) } func runAuthLogin(cmd *cobra.Command, _ []string) error { cfg, err := loadConfig() if err != nil { return err } // Resolve host: --host on login, else the global --host, else prompt, else default. host := authLoginHost if host == "" { host = flagHost } token := authLoginToken switch { case authLoginWithToken: // Read the token from stdin (trimmed). b, err := io.ReadAll(cmd.InOrStdin()) if err != nil { return fmt.Errorf("read token from stdin: %w", err) } token = strings.TrimSpace(string(b)) case token == "": // Browser (device) flow: approve on the website, nothing copy-pasted. if host == "" { host = prompt(cmd, fmt.Sprintf("rickub host [%s]: ", config.DefaultHost)) if host == "" { host = config.DefaultHost } } host = config.NormalizeHost(host) token, err = loginViaBrowser(cmd, host) if err != nil { return err } } token = strings.TrimSpace(token) if token == "" { return fmt.Errorf("a token is required") } if host == "" { host = config.DefaultHost } host = config.NormalizeHost(host) config.WarnIfInsecure(cmd.ErrOrStderr(), host) // Verify the token before persisting it. client := api.New(host, token) user, err := client.GetUser(cmd.Context()) if err != nil { return fmt.Errorf("token verification failed against %s: %w", host, err) } // Store the token under the host it was just verified against, so it is // never sent anywhere else. cfg.SetToken(host, token) if err := cfg.Save(); err != nil { return err } path, _ := config.Path() fmt.Fprintf(cmd.OutOrStdout(), "Logged in to %s as %s (config: %s)\n", host, user.Handle, path) return nil } func runAuthStatus(cmd *cobra.Command, _ []string) error { cfg, err := loadConfig() if err != nil { return err } host := config.ResolveHost(flagHost, cfg) token := config.ResolveToken(flagToken, cfg, host) out := cmd.OutOrStdout() fmt.Fprintf(out, "Host: %s\n", host) if token == "" { if cfg.Host != "" && cfg.TokenFor(cfg.Host) != "" { fmt.Fprintf(out, "No token stored for this host (the stored token belongs to %s).\n", cfg.Host) fmt.Fprintf(out, "Run `rickub auth login --host %s`.\n", host) } else { fmt.Fprintln(out, "Not logged in (no token). Run `rickub auth login`.") } return fmt.Errorf("not logged in") } switch { case flagToken != "": fmt.Fprintln(out, "Token source: --token flag") case os.Getenv(config.EnvToken) != "": fmt.Fprintf(out, "Token source: %s\n", config.EnvToken) default: fmt.Fprintf(out, "Token source: config file (bound to %s)\n", host) } config.WarnIfInsecure(cmd.ErrOrStderr(), host) client := api.New(host, token) user, err := client.GetUser(cmd.Context()) if err != nil { fmt.Fprintf(out, "Token: %s (invalid)\n", redact(token)) return fmt.Errorf("token check failed: %w", err) } fmt.Fprintf(out, "Token: %s (valid)\n", redact(token)) fmt.Fprintf(out, "Logged in as: %s", user.Handle) if user.DisplayName != "" { fmt.Fprintf(out, " (%s)", user.DisplayName) } fmt.Fprintln(out) return nil } func runAuthLogout(cmd *cobra.Command, _ []string) error { cfg, err := loadConfig() if err != nil { return err } // Log out of the host currently in effect, not every host at once. host := config.ResolveHost(flagHost, cfg) if !cfg.ClearToken(host) { fmt.Fprintf(cmd.OutOrStdout(), "No stored token for %s to remove.\n", host) return nil } if err := cfg.Save(); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "Logged out of %s (token removed).\n", host) return nil } // loginViaBrowser runs the device flow against host: it starts a login request, // shows the approval URL + code (optionally opening a browser), and polls until // the user approves, denies, or the code expires. It returns the minted token. func loginViaBrowser(cmd *cobra.Command, host string) (string, error) { if authLoginScope != "read" && authLoginScope != "all" { return "", fmt.Errorf("invalid --scope %q (all or read)", authLoginScope) } clientName := "rickub CLI" if hn, err := os.Hostname(); err == nil && hn != "" { clientName += " on " + hn } client := api.New(host, "") start, err := client.StartDeviceLogin(cmd.Context(), authLoginScope, clientName) if err != nil { return "", fmt.Errorf("start device login against %s: %w", host, err) } out := cmd.OutOrStdout() fmt.Fprintln(out) fmt.Fprintln(out, " Sign in with your rickub account.") fmt.Fprintf(out, " Open %s and enter code:\n", start.VerificationURL) fmt.Fprintf(out, "\n %s\n\n", start.UserCode) if !authLoginNoBrowser { // The URL comes from the server: never hand an arbitrary scheme to the // platform opener, which would happily launch a registered handler for // it. Anything but http/https is printed for the user to judge. if err := checkBrowserURL(start.VerificationURIComplete); err != nil { fmt.Fprintf(out, " Not opening a browser: %v\n", err) fmt.Fprintf(out, " Open this URL yourself if you trust it: %s\n", start.VerificationURIComplete) } else if err := openBrowser(start.VerificationURIComplete); err == nil { fmt.Fprintln(out, " Opening your browser…") } } interval := time.Duration(start.Interval) * time.Second if interval < time.Second { interval = time.Second } deadline := time.Now().Add(time.Duration(start.ExpiresIn) * time.Second) ticker := time.NewTicker(interval) defer ticker.Stop() fmt.Fprintf(out, " Waiting for approval (expires in %ds)…\n", start.ExpiresIn) for { select { case <-cmd.Context().Done(): return "", cmd.Context().Err() case <-ticker.C: } if time.Now().After(deadline) { return "", fmt.Errorf("the sign-in code expired; run `rickub auth login` again") } tok, err := client.PollDeviceToken(cmd.Context(), start.DeviceCode) if err == nil { fmt.Fprintln(out, " Approved.") return tok.AccessToken, nil } var apiErr *api.APIError if !errors.As(err, &apiErr) { return "", err } switch apiErr.Code { case "authorization_pending": // keep waiting case "slow_down": interval *= 2 ticker.Reset(interval) default: // access_denied, expired_token, invalid_grant: all terminal. return "", fmt.Errorf("sign-in failed: %s", apiErr.Message) } } } // prompt writes a message and reads a trimmed line from stdin. func prompt(cmd *cobra.Command, msg string) string { fmt.Fprint(cmd.OutOrStdout(), msg) r := bufio.NewReader(cmd.InOrStdin()) line, _ := r.ReadString('\n') return strings.TrimSpace(line) } // tokenPrefix is the non-secret marker every rickub PAT starts with; the bytes // after it are secret material and are never displayed. const tokenPrefix = "rickub_pat_" // redact masks a token for display. It shows only the non-secret prefix, so the // output identifies the kind of credential without leaking any of it. func redact(token string) string { if strings.HasPrefix(token, tokenPrefix) { return tokenPrefix + "…" } return "****" }