mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
748 lines
22 KiB
Go
748 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/warmbly/warmbly/internal/app/apikey"
|
|
"github.com/warmbly/warmbly/internal/app/oauth"
|
|
"github.com/warmbly/warmbly/internal/cli/api"
|
|
"github.com/warmbly/warmbly/internal/cli/config"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
func newAuthCmd(f *Factory) *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "auth <command>",
|
|
Short: "Sign in, sign out, and see who you are",
|
|
GroupID: groupCore,
|
|
Long: `Sign the CLI in to a Warmbly instance.
|
|
|
|
Signing in through the browser creates one API key named for this machine. It
|
|
appears under Settings > API keys and can be revoked there or with
|
|
` + "`warmbly auth logout`" + `. Credentials are written to ` + config.HostsPath() + `
|
|
at 0600, and WARMBLY_TOKEN overrides the file without ever being written to it,
|
|
which is how CI authenticates with no login step.`,
|
|
}
|
|
cmd.AddCommand(
|
|
newAuthLoginCmd(f),
|
|
newAuthStatusCmd(f),
|
|
newAuthTokenCmd(f),
|
|
newAuthSwitchCmd(f),
|
|
newAuthRefreshCmd(f),
|
|
newAuthLogoutCmd(f),
|
|
)
|
|
return cmd
|
|
}
|
|
|
|
// parseScopes accepts the two presets plus any comma or space separated list
|
|
// of scope names, in either case, so `--scopes read_campaigns,READ_CONTACTS`
|
|
// works and a typo is named rather than silently dropped.
|
|
func parseScopes(raw string) (uint64, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
switch strings.ToLower(raw) {
|
|
case "":
|
|
return models.APIPermFullAccess, nil
|
|
case "full", "full-access", "all":
|
|
return models.APIPermFullAccess, nil
|
|
case "read", "read-only", "readonly":
|
|
return models.APIPermReadOnly, nil
|
|
}
|
|
mask, unknown := oauth.ParseScopes(strings.NewReplacer(",", " ", "+", " ").Replace(raw))
|
|
if len(unknown) > 0 {
|
|
return 0, fmt.Errorf("unknown scope %s.\nRun `warmbly key permissions` for the full list, or use the presets: full, read-only.", strings.Join(unknown, ", "))
|
|
}
|
|
if mask == 0 {
|
|
return 0, fmt.Errorf("--scopes granted nothing. Name at least one scope, or use full or read-only.")
|
|
}
|
|
return mask, nil
|
|
}
|
|
|
|
func newAuthLoginCmd(f *Factory) *cobra.Command {
|
|
var (
|
|
hostname string
|
|
apiURL string
|
|
withToken bool
|
|
web bool
|
|
scopeStr string
|
|
force bool
|
|
)
|
|
cmd := &cobra.Command{
|
|
Use: "login",
|
|
Short: "Sign in to a Warmbly instance",
|
|
Long: `Sign in to a Warmbly instance.
|
|
|
|
With no flags this asks which instance, then how: a browser approval that
|
|
creates a key for this machine, or pasting a key you already have.`,
|
|
Example: ` $ warmbly auth login
|
|
$ warmbly auth login --hostname warmbly.acme.com
|
|
$ warmbly auth login --scopes read-only
|
|
$ echo $KEY | warmbly auth login --with-token`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(c *cobra.Command, _ []string) error {
|
|
return runAuthLogin(c.Context(), f, hostname, apiURL, withToken, web, scopeStr, force)
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&hostname, "hostname", "", "Instance to sign in to (default: warmbly.com)")
|
|
cmd.Flags().StringVar(&apiURL, "api-url", "", "API base URL, when it is not derivable from the hostname")
|
|
cmd.Flags().BoolVar(&withToken, "with-token", false, "Read an API key from stdin instead of using the browser")
|
|
cmd.Flags().BoolVarP(&web, "web", "w", false, "Go straight to the browser approval")
|
|
cmd.Flags().StringVarP(&scopeStr, "scopes", "s", "", "Scopes to request: full, read-only, or a list of names")
|
|
cmd.Flags().BoolVar(&force, "force", false, "Replace an existing sign-in for this host without asking")
|
|
return cmd
|
|
}
|
|
|
|
func runAuthLogin(ctx context.Context, f *Factory, hostname, apiURL string, withToken, web bool, scopeStr string, force bool) error {
|
|
io := f.IO
|
|
cfg, err := f.Config()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hosts, err := f.Hosts()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 1. Which instance.
|
|
if hostname == "" && apiURL == "" {
|
|
if !io.IsStdinTTY() {
|
|
hostname = config.DefaultHost
|
|
} else {
|
|
idx, serr := io.Select("Where do you want to sign in?", []string{
|
|
"warmbly.com (the hosted service)",
|
|
"A self-hosted instance",
|
|
})
|
|
if serr != nil {
|
|
return serr
|
|
}
|
|
if idx == 0 {
|
|
hostname = config.DefaultHost
|
|
} else {
|
|
answer, ierr := io.Input("Instance hostname (for example warmbly.acme.com)", "")
|
|
if ierr != nil {
|
|
return ierr
|
|
}
|
|
if strings.TrimSpace(answer) == "" {
|
|
return fmt.Errorf("a hostname is required to sign in to a self-hosted instance")
|
|
}
|
|
hostname = answer
|
|
}
|
|
}
|
|
}
|
|
if hostname == "" {
|
|
hostname = apiURL
|
|
}
|
|
host := config.NormalizeHost(hostname)
|
|
|
|
if existing := hosts[host]; existing != nil && !force {
|
|
if !io.IsStdinTTY() {
|
|
return fmt.Errorf("already signed in to %s as %s. Pass --force to replace that sign-in.", host, existing.User)
|
|
}
|
|
ok, cerr := io.Confirm(fmt.Sprintf("Already signed in to %s as %s. Sign in again?", host, existing.User), false)
|
|
if cerr != nil {
|
|
return cerr
|
|
}
|
|
if !ok {
|
|
return errCancelled
|
|
}
|
|
}
|
|
|
|
// 2. Where its API is.
|
|
base, err := resolveAPIBase(ctx, f, host, apiURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 3. How to authenticate.
|
|
useToken := withToken
|
|
if !withToken && !web && io.IsStdinTTY() {
|
|
idx, serr := io.Select("How do you want to sign in?", []string{
|
|
"Approve in a browser (creates a key for this machine)",
|
|
"Paste an API key you already have",
|
|
})
|
|
if serr != nil {
|
|
return serr
|
|
}
|
|
useToken = idx == 1
|
|
}
|
|
|
|
scopes, err := parseScopes(scopeStr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var entry *config.Host
|
|
if useToken {
|
|
entry, err = loginWithToken(ctx, f, base)
|
|
} else {
|
|
entry, err = loginWithBrowser(ctx, f, cfg, base, scopes)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
hosts[host] = entry
|
|
if err := hosts.Save(); err != nil {
|
|
return err
|
|
}
|
|
// Signing in makes that host the active one. Nobody expects to sign in and
|
|
// still have the next command talk to somewhere else.
|
|
if cfg.ActiveHost != host {
|
|
cfg.ActiveHost = host
|
|
if err := cfg.Save(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
io.Errorf("%s Signed in to %s as %s\n", io.Tick(), io.Bold(host), io.Bold(entry.User))
|
|
if entry.Organization != "" {
|
|
io.Errorf(" Workspace %s\n", entry.Organization)
|
|
}
|
|
io.Errorf(" Credentials written to %s\n", config.HostsPath())
|
|
return nil
|
|
}
|
|
|
|
// resolveAPIBase finds the API for a host, probing the layouts the installer
|
|
// writes. Guessing wrong here produces a confusing 404 on every later command,
|
|
// so it is settled once, at sign-in, and stored.
|
|
func resolveAPIBase(ctx context.Context, f *Factory, host, explicit string) (string, error) {
|
|
if explicit != "" {
|
|
return strings.TrimRight(explicit, "/"), nil
|
|
}
|
|
if v := strings.TrimSpace(os.Getenv(config.APIURLEnv)); v != "" {
|
|
return strings.TrimRight(v, "/"), nil
|
|
}
|
|
if host == config.DefaultHost {
|
|
return config.DefaultAPIURL(host), nil
|
|
}
|
|
|
|
candidates := config.CandidateAPIURLs(host)
|
|
for _, base := range candidates {
|
|
if reachable(ctx, f, base) {
|
|
if f.Debug {
|
|
f.IO.Errorf("* resolved API base %s\n", base)
|
|
}
|
|
return base, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("could not find a Warmbly API for %s. Tried:\n %s\nPass --api-url with the instance's API base URL (the API_PUBLIC_URL it is configured with).", host, strings.Join(candidates, "\n "))
|
|
}
|
|
|
|
// deploymentConfig is the slice of GET /auth/config the CLI uses: enough to
|
|
// tell a Warmbly API from any other 200, plus the two URLs a client cannot
|
|
// derive on a self-hosted instance.
|
|
type deploymentConfig struct {
|
|
Registration string `json:"registration"`
|
|
AppURL string `json:"app_url"`
|
|
WebsocketURL string `json:"websocket_url"`
|
|
}
|
|
|
|
// fetchDeploymentConfig reads the public deployment facts, or nil when the
|
|
// base is not a Warmbly API. /health alone would accept any 200.
|
|
func fetchDeploymentConfig(ctx context.Context, f *Factory, base string) *deploymentConfig {
|
|
client := api.New(base, "", UserAgent())
|
|
client.HTTP.Timeout = 8 * time.Second
|
|
if f.Debug {
|
|
client.Debug = f.IO.ErrOut
|
|
}
|
|
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
resp, err := client.Do(probeCtx, api.Request{Method: http.MethodGet, Path: "/auth/config", Anonymous: true})
|
|
if err != nil || resp == nil || resp.Status != http.StatusOK {
|
|
return nil
|
|
}
|
|
var probe deploymentConfig
|
|
if json.Unmarshal(resp.Body, &probe) != nil || probe.Registration == "" {
|
|
return nil
|
|
}
|
|
return &probe
|
|
}
|
|
|
|
func reachable(ctx context.Context, f *Factory, base string) bool {
|
|
return fetchDeploymentConfig(ctx, f, base) != nil
|
|
}
|
|
|
|
func loginWithToken(ctx context.Context, f *Factory, base string) (*config.Host, error) {
|
|
io := f.IO
|
|
if io.IsStdinTTY() {
|
|
io.Errorln(io.Gray("Create a key under Settings > API keys, then paste it here. It is not echoed."))
|
|
}
|
|
token, err := io.Secret("API key")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return nil, fmt.Errorf("no key was given")
|
|
}
|
|
if !strings.HasPrefix(token, apikey.KeyPrefix) {
|
|
return nil, fmt.Errorf("that does not look like a Warmbly API key: it should start with %q", apikey.KeyPrefix)
|
|
}
|
|
|
|
entry := &config.Host{APIURL: base, Token: token, AddedAt: time.Now().UTC()}
|
|
if err := fillIdentity(ctx, f, entry); err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg := fetchDeploymentConfig(ctx, f, base); cfg != nil {
|
|
entry.AppURL = cfg.AppURL
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
func loginWithBrowser(ctx context.Context, f *Factory, cfg *config.Config, base string, scopes uint64) (*config.Host, error) {
|
|
io := f.IO
|
|
client := api.New(base, "", UserAgent())
|
|
if f.Debug {
|
|
client.Debug = io.ErrOut
|
|
}
|
|
|
|
machine, _ := os.Hostname()
|
|
start, err := startDeviceFlow(ctx, client, machine, scopes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
target := start.VerificationURLComplete
|
|
if target == "" {
|
|
target = start.VerificationURL
|
|
}
|
|
io.Errorf("\n %s %s\n", io.Gray("Your code:"), io.Bold(start.UserCode))
|
|
io.Errorf(" %s %s\n\n", io.Gray("Approve at:"), target)
|
|
|
|
if io.IsStdinTTY() {
|
|
if ok, cerr := io.Confirm("Open that in your browser now?", true); cerr == nil && ok {
|
|
if berr := openBrowser(cfg, target); berr != nil {
|
|
io.Errorln(io.Gray("Could not open a browser. Use the link above."))
|
|
}
|
|
}
|
|
}
|
|
|
|
io.Errorf("%s Waiting for approval (the code expires in %d minutes)\n", io.Gray("…"), start.ExpiresIn/60)
|
|
result, err := pollDeviceFlow(ctx, client, start)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
entry := &config.Host{
|
|
APIURL: base,
|
|
AppURL: appURLFromVerification(start.VerificationURL),
|
|
Token: result.Token,
|
|
User: result.UserEmail,
|
|
UserID: result.UserID,
|
|
Organization: result.OrganizationName,
|
|
OrganizationID: result.OrganizationID,
|
|
Scopes: result.ScopeNames,
|
|
APIKeyID: result.APIKeyID,
|
|
AddedAt: time.Now().UTC(),
|
|
}
|
|
if entry.User == "" {
|
|
// The approval did not carry an identity; ask the API who we are.
|
|
if err := fillIdentity(ctx, f, entry); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
// appURLFromVerification recovers the dashboard origin from the approval link
|
|
// the instance just handed us, which is the instance's own APP_URL and so is
|
|
// exact where a hostname guess is not.
|
|
func appURLFromVerification(raw string) string {
|
|
return strings.TrimSuffix(strings.TrimSpace(raw), "/cli")
|
|
}
|
|
|
|
// fillIdentity calls /me, which both validates the credential and gives the
|
|
// host entry the labels `auth status` prints.
|
|
func fillIdentity(ctx context.Context, f *Factory, entry *config.Host) error {
|
|
client := api.New(entry.APIURL, entry.Token, UserAgent())
|
|
if f.Debug {
|
|
client.Debug = f.IO.ErrOut
|
|
}
|
|
var id models.Identity
|
|
if err := client.JSON(ctx, api.Request{Method: http.MethodGet, Path: "/me"}, &id); err != nil {
|
|
if api.StatusOf(err) == http.StatusUnauthorized {
|
|
return fmt.Errorf("that key was rejected by %s. Check it is a key for this instance and has not been revoked.", entry.APIURL)
|
|
}
|
|
return err
|
|
}
|
|
entry.User = id.Email
|
|
entry.UserID = id.UserID.String()
|
|
entry.Scopes = id.Scopes
|
|
entry.Organization = id.OrganizationName
|
|
if id.OrganizationID != nil {
|
|
entry.OrganizationID = id.OrganizationID.String()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newAuthStatusCmd(f *Factory) *cobra.Command {
|
|
var showToken bool
|
|
cmd := &cobra.Command{
|
|
Use: "status",
|
|
Short: "Show which hosts you are signed in to",
|
|
Long: `Show every signed-in host, who you are on it, and where the credential
|
|
came from. Run this first when a command fails with a credential error: an
|
|
environment variable nobody remembers exporting is the usual answer.`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(c *cobra.Command, _ []string) error {
|
|
return runAuthStatus(c.Context(), f, showToken)
|
|
},
|
|
}
|
|
cmd.Flags().BoolVarP(&showToken, "show-token", "t", false, "Print the token itself")
|
|
return cmd
|
|
}
|
|
|
|
func runAuthStatus(ctx context.Context, f *Factory, showToken bool) error {
|
|
io := f.IO
|
|
cfg, err := f.Config()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hosts, err := f.Hosts()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resolved, resolveErr := f.Resolved()
|
|
names := hosts.Names()
|
|
// A token from the environment points at a host that may not be in the
|
|
// file at all; it still deserves a line.
|
|
if resolveErr == nil && hosts[resolved.Host] == nil {
|
|
names = append(names, resolved.Host)
|
|
}
|
|
|
|
if len(names) == 0 {
|
|
io.Errorf("%s Not signed in anywhere.\n", io.Cross())
|
|
io.Errorln(io.Gray("Run `warmbly auth login` to sign in."))
|
|
return errSilent
|
|
}
|
|
sort.Strings(names)
|
|
|
|
failed := false
|
|
for _, name := range names {
|
|
active := resolveErr == nil && name == resolved.Host
|
|
marker := " "
|
|
if active {
|
|
marker = io.Green("* ")
|
|
}
|
|
io.Printf("%s%s\n", marker, io.Bold(name))
|
|
|
|
entry := hosts[name]
|
|
token := ""
|
|
source := config.HostsPath()
|
|
base := ""
|
|
if entry != nil {
|
|
token, base = entry.Token, entry.APIURL
|
|
}
|
|
if active {
|
|
token, source, base = resolved.Token, resolved.Source, resolved.APIURL
|
|
}
|
|
|
|
if token == "" {
|
|
io.Printf(" %s no credential\n", io.Cross())
|
|
failed = true
|
|
continue
|
|
}
|
|
|
|
probe := &config.Host{APIURL: base, Token: token}
|
|
if err := fillIdentity(ctx, f, probe); err != nil {
|
|
io.Printf(" %s %s\n", io.Cross(), err.Error())
|
|
failed = true
|
|
} else {
|
|
io.Printf(" %s signed in as %s\n", io.Tick(), io.Bold(probe.User))
|
|
if probe.Organization != "" {
|
|
io.Printf(" - workspace: %s\n", probe.Organization)
|
|
}
|
|
io.Printf(" - scopes: %s\n", scopeSummary(probe.Scopes))
|
|
}
|
|
io.Printf(" - api: %s\n", base)
|
|
io.Printf(" - token from: %s\n", source)
|
|
if showToken {
|
|
io.Printf(" - token: %s\n", token)
|
|
} else {
|
|
io.Printf(" - token: %s\n", maskToken(token))
|
|
}
|
|
}
|
|
|
|
if cfg.ActiveHost != "" && len(names) > 1 {
|
|
io.Println()
|
|
io.Println(io.Gray("The * host is the one commands use. `warmbly auth switch` changes it."))
|
|
}
|
|
if failed {
|
|
return errSilent
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// scopeSummary keeps a full-access key from printing twenty-four lines.
|
|
func scopeSummary(scopes []string) string {
|
|
if len(scopes) == 0 {
|
|
return "none reported"
|
|
}
|
|
if len(scopes) >= len(models.AllAPIPermissions) {
|
|
return fmt.Sprintf("all %d", len(scopes))
|
|
}
|
|
if len(scopes) > 6 {
|
|
return fmt.Sprintf("%s and %d more", strings.Join(scopes[:6], ", "), len(scopes)-6)
|
|
}
|
|
return strings.Join(scopes, ", ")
|
|
}
|
|
|
|
func maskToken(t string) string {
|
|
if len(t) <= 12 {
|
|
return strings.Repeat("*", len(t))
|
|
}
|
|
return t[:8] + strings.Repeat("*", 8) + t[len(t)-4:]
|
|
}
|
|
|
|
func newAuthTokenCmd(f *Factory) *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "token",
|
|
Short: "Print the token the CLI is using",
|
|
Long: `Print the active token on stdout and nothing else, so it can be piped
|
|
into another tool or exported into a CI environment.`,
|
|
Example: ` $ export WARMBLY_TOKEN=$(warmbly auth token)
|
|
$ curl -H "Authorization: Bearer $(warmbly auth token)" https://api.warmbly.com/v1/me`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(*cobra.Command, []string) error {
|
|
r, err := f.Resolved()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f.IO.Println(r.Token)
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func newAuthSwitchCmd(f *Factory) *cobra.Command {
|
|
var hostname string
|
|
cmd := &cobra.Command{
|
|
Use: "switch",
|
|
Short: "Change which signed-in host commands use",
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(c *cobra.Command, args []string) error {
|
|
if len(args) == 1 {
|
|
hostname = args[0]
|
|
}
|
|
cfg, err := f.Config()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hosts, err := f.Hosts()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
names := hosts.Names()
|
|
if len(names) == 0 {
|
|
return fmt.Errorf("not signed in anywhere. Run `warmbly auth login` first.")
|
|
}
|
|
if hostname == "" {
|
|
if len(names) == 1 {
|
|
hostname = names[0]
|
|
} else {
|
|
labels := make([]string, len(names))
|
|
for i, n := range names {
|
|
labels[i] = n
|
|
if hosts[n].User != "" {
|
|
labels[i] += " " + f.IO.Gray(hosts[n].User)
|
|
}
|
|
}
|
|
idx, serr := f.IO.Select("Use which host?", labels)
|
|
if serr != nil {
|
|
return serr
|
|
}
|
|
hostname = names[idx]
|
|
}
|
|
}
|
|
host := config.NormalizeHost(hostname)
|
|
if hosts[host] == nil {
|
|
return fmt.Errorf("not signed in to %s. Signed in to: %s", host, strings.Join(names, ", "))
|
|
}
|
|
cfg.ActiveHost = host
|
|
if err := cfg.Save(); err != nil {
|
|
return err
|
|
}
|
|
f.IO.Errorf("%s Now using %s\n", f.IO.Tick(), f.IO.Bold(host))
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&hostname, "hostname", "", "Host to switch to")
|
|
return cmd
|
|
}
|
|
|
|
func newAuthRefreshCmd(f *Factory) *cobra.Command {
|
|
var scopeStr string
|
|
cmd := &cobra.Command{
|
|
Use: "refresh",
|
|
Short: "Sign in again, usually to add scopes",
|
|
Long: `Run the browser sign-in again for the active host.
|
|
|
|
A key's scopes are fixed when it is created, so widening what the CLI may do
|
|
means a new key. The old one is revoked once the new one works.`,
|
|
Example: ` $ warmbly auth refresh --scopes full
|
|
$ warmbly auth refresh --scopes read_campaigns,send_campaigns`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(c *cobra.Command, _ []string) error {
|
|
r, err := f.Resolved()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
old := r.Entry
|
|
if err := runAuthLogin(c.Context(), f, r.Host, r.APIURL, false, true, scopeStr, true); err != nil {
|
|
return err
|
|
}
|
|
// Revoke the key we replaced, so refreshing does not accumulate a
|
|
// key per run under Settings > API keys.
|
|
if old != nil && old.APIKeyID != "" {
|
|
if err := revokeKey(c.Context(), f, old.APIKeyID); err != nil && f.Debug {
|
|
f.IO.Errorf("* could not revoke the previous key: %v\n", err)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVarP(&scopeStr, "scopes", "s", "", "Scopes to request: full, read-only, or a list of names")
|
|
return cmd
|
|
}
|
|
|
|
func newAuthLogoutCmd(f *Factory) *cobra.Command {
|
|
var hostname string
|
|
var keepKey bool
|
|
cmd := &cobra.Command{
|
|
Use: "logout",
|
|
Short: "Sign out and revoke this machine's key",
|
|
Long: `Forget a host's credential.
|
|
|
|
The key the sign-in created is revoked on the instance too, so signing out on
|
|
a machine you are handing back actually ends its access. --keep-key skips the
|
|
revocation, for a key you pasted in and use elsewhere.`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(c *cobra.Command, _ []string) error {
|
|
return runAuthLogout(c.Context(), f, hostname, keepKey)
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&hostname, "hostname", "", "Host to sign out of (default: the active one)")
|
|
cmd.Flags().BoolVar(&keepKey, "keep-key", false, "Forget the credential locally without revoking it")
|
|
return cmd
|
|
}
|
|
|
|
func runAuthLogout(ctx context.Context, f *Factory, hostname string, keepKey bool) error {
|
|
io := f.IO
|
|
cfg, err := f.Config()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hosts, err := f.Hosts()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
host := config.NormalizeHost(hostname)
|
|
if hostname == "" {
|
|
r, rerr := f.Resolved()
|
|
if rerr != nil {
|
|
return rerr
|
|
}
|
|
host = r.Host
|
|
}
|
|
entry := hosts[host]
|
|
if entry == nil {
|
|
return fmt.Errorf("not signed in to %s", host)
|
|
}
|
|
|
|
if !f.AssumeYes && io.IsStdinTTY() {
|
|
ok, cerr := io.Confirm(fmt.Sprintf("Sign out of %s as %s?", host, entry.User), false)
|
|
if cerr != nil {
|
|
return cerr
|
|
}
|
|
if !ok {
|
|
return errCancelled
|
|
}
|
|
}
|
|
|
|
// Revoke first: if it fails, the credential is still in the file and the
|
|
// user can retry, which is better than a live key nobody can reach.
|
|
revoked := false
|
|
if !keepKey && entry.APIKeyID != "" {
|
|
f.hosts = hosts
|
|
if err := revokeKeyWith(ctx, f, entry, entry.APIKeyID); err != nil {
|
|
io.Errorf("%s Could not revoke the key on %s: %v\n", io.Yellow("!"), host, err)
|
|
io.Errorln(io.Gray("Revoke it by hand under Settings > API keys."))
|
|
} else {
|
|
revoked = true
|
|
}
|
|
}
|
|
|
|
delete(hosts, host)
|
|
if err := hosts.Save(); err != nil {
|
|
return err
|
|
}
|
|
if cfg.ActiveHost == host {
|
|
cfg.ActiveHost = ""
|
|
if names := hosts.Names(); len(names) == 1 {
|
|
cfg.ActiveHost = names[0]
|
|
}
|
|
if err := cfg.Save(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
io.Errorf("%s Signed out of %s\n", io.Tick(), io.Bold(host))
|
|
if revoked {
|
|
io.Errorln(io.Gray("The key this machine was using has been revoked."))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func revokeKey(ctx context.Context, f *Factory, keyID string) error {
|
|
r, err := f.Resolved()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return revokeKeyWith(ctx, f, r.Entry, keyID)
|
|
}
|
|
|
|
// revokeKeyWith ends a key using the credential in entry.
|
|
//
|
|
// It asks the instance to revoke the calling credential first: self-revocation
|
|
// needs no scope, so it works for a read-only sign-in, where deleting by id
|
|
// would be refused. The by-id path is the fallback for an instance that
|
|
// predates /api-keys/self, and for revoking a key that is not the caller.
|
|
func revokeKeyWith(ctx context.Context, f *Factory, entry *config.Host, keyID string) error {
|
|
if entry == nil {
|
|
return fmt.Errorf("no credential to revoke with")
|
|
}
|
|
client := api.New(entry.APIURL, entry.Token, UserAgent())
|
|
if f.Debug {
|
|
client.Debug = f.IO.ErrOut
|
|
}
|
|
|
|
if keyID == "" || keyID == entry.APIKeyID {
|
|
_, err := client.Do(ctx, api.Request{Method: http.MethodDelete, Path: "/api-keys/self"})
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if status := api.StatusOf(err); status != http.StatusNotFound && status != http.StatusMethodNotAllowed {
|
|
return err
|
|
}
|
|
if keyID == "" {
|
|
return err
|
|
}
|
|
}
|
|
|
|
_, err := client.Do(ctx, api.Request{Method: http.MethodDelete, Path: "/api-keys/" + keyID})
|
|
if err != nil && api.StatusOf(err) == http.StatusNotFound {
|
|
// Already gone is the outcome we wanted.
|
|
return nil
|
|
}
|
|
return err
|
|
}
|