// The `carlos auth` verb: CLI identity. // // Before this, the CLI's authority was "whoever holds the release key or can // reach the box", and `carlos accounts create` had nobody to name as an // account's owner — which produced accounts their own creator could not open // in the console. `carlos auth login` gives the command line an identity, and // that identity is what owns the accounts it creates. // // The flow, and why it is shaped this way: // // carlos auth login --console https://platform.carlosframework.com // → POST /auth/cli/start the console mints a short typed code // for the human and a long device code // for this process // → print the URL and the code the human opens it in ANY browser they // are signed in to and approves // → POST /auth/cli/poll (loop) the console hands the token back down // this channel, once // → ~/.carlos/credentials mode 0600, one entry per console origin // // There is deliberately no local callback listener. A browser-redirect flow // needs the browser to reach a port on the machine running the CLI, and the // machine running the CLI is very often an SSH session on a box or a CI // shell where that is simply false. A code you can retype from a phone works // everywhere, which is the whole point. // // The console — not Keymail — is the authority here. It is already a // registered Keymail OAuth client and already knows your accounts and roles, // so the CLI trusts the deployment the operator actually runs. package main import ( "bytes" "context" "encoding/json" "errors" "flag" "fmt" "io" "mime/multipart" "net" "net/http" "net/url" "os" "os/user" "path/filepath" "strings" "time" ) const authUsage = `usage: carlos auth login [--console ] log this terminal in to a deployment's console (default: the configured default console, else https://platform.carlosframework.com). Prints a code; you approve it in a browser that is already signed in to that console. No browser on this machine is needed — the code travels. [--label ] what to call this terminal on the console's command-line access page (default: user@host) whoami [--console ] ask the console who this terminal is logout [--console ] revoke this terminal's token and forget it default [--project] [-d ] [] show the default console and where it is set, or set it: writes ~/.carlos/config — or, with --project, ./.carlos/config, which overrides the global and is meant to be committed so a checkout pins its own deployment. -d reads or writes that destination's console ("carlos config destinations" manages the rest) The token lands in ~/.carlos/credentials (mode 0600), one entry per console origin — a laptop can hold credentials for the flagship and a self-hosted deployment at once. --console may be omitted when there is exactly one, or when a default is set. HEADLESS AND BOX-SIDE USE: do not log in. A box has no browser and no human, and a long-lived token on it is a standing credential nobody is watching. Pass --owner
to "carlos accounts create" instead — that is the documented path, and it needs no identity of its own. ` // RunAuth dispatches the auth sub-verbs. func RunAuth(args []string) { if len(args) == 0 { fmt.Fprint(os.Stderr, authUsage) os.Exit(1) } var err error switch args[0] { case "login": err = authLogin(os.Stdout, args[1:]) case "whoami": err = authWhoami(os.Stdout, args[1:]) case "logout": err = authLogout(os.Stdout, args[1:]) case "default": err = authDefault(os.Stdout, args[1:]) default: fmt.Fprint(os.Stderr, authUsage) os.Exit(1) } if err != nil { fmt.Fprintf(os.Stderr, "%s %v\n", colorFor(os.Stderr).bad("carlos auth "+args[0]+":"), err) os.Exit(1) } } // ---- the credentials file --------------------------------------------------- // credential is one console's token. The token is the whole secret: it is // never printed, never logged, and the file it lives in is 0600. type credential struct { Console string `json:"console"` Address string `json:"address"` Token string `json:"token"` Label string `json:"label,omitempty"` CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at"` } // Expired reports whether this credential is past its console-declared life. // A zero ExpiresAt is treated as live: a console that declined to say is not // the same as one that said "now". func (c credential) Expired(now time.Time) bool { return !c.ExpiresAt.IsZero() && now.After(c.ExpiresAt) } // credentialFile is the whole file. A list rather than a map so the on-disk // order is stable and a human can read it. type credentialFile struct { Version int `json:"version"` Credentials []credential `json:"credentials"` } // credentialsPath is CARLOS_CREDENTIALS, else ~/.carlos/credentials. The env // override exists for tests and for anyone keeping their credential somewhere // other than $HOME (a per-project shell, a tmpfs). func credentialsPath() string { if p := os.Getenv("CARLOS_CREDENTIALS"); p != "" { return p } home, err := os.UserHomeDir() if err != nil { return ".carlos/credentials" } return filepath.Join(home, ".carlos", "credentials") } // loadCredentials reads the file. A missing file is an empty one, not an // error: "you are not logged in" is a state, and every caller has to handle // it anyway. func loadCredentials() (*credentialFile, error) { f := &credentialFile{Version: 1} raw, err := os.ReadFile(credentialsPath()) if errors.Is(err, os.ErrNotExist) { return f, nil } if err != nil { return nil, err } if len(bytes.TrimSpace(raw)) == 0 { return f, nil } if err := json.Unmarshal(raw, f); err != nil { return nil, fmt.Errorf("%s is not readable as credentials: %w", credentialsPath(), err) } f.Version = 1 return f, nil } // save writes the file 0600, through a temp file in the same directory so a // crash mid-write cannot leave a truncated credential store. func (f *credentialFile) save() error { path := credentialsPath() dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o700); err != nil { return err } f.Version = 1 body, err := json.MarshalIndent(f, "", " ") if err != nil { return err } body = append(body, '\n') tmp, err := os.CreateTemp(dir, ".credentials-*") if err != nil { return err } defer os.Remove(tmp.Name()) // Chmod before the write, not after: the secret must never exist on disk, // even for a moment, at the temp file's default permissions. if err := tmp.Chmod(0o600); err != nil { tmp.Close() return err } if _, err := tmp.Write(body); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmp.Name(), path) } // find returns the credential for a console origin, or nil. func (f *credentialFile) find(console string) *credential { want, err := normalizeConsole(console) if err != nil { return nil } for i := range f.Credentials { if f.Credentials[i].Console == want { return &f.Credentials[i] } } return nil } // put stores a credential, replacing any existing entry for that console. // One entry per origin: logging in again to one deployment must never disturb // another. func (f *credentialFile) put(c credential) { if n, err := normalizeConsole(c.Console); err == nil { c.Console = n } for i := range f.Credentials { if f.Credentials[i].Console == c.Console { f.Credentials[i] = c return } } f.Credentials = append(f.Credentials, c) } // remove drops a console's credential, reporting whether there was one. func (f *credentialFile) remove(console string) bool { want, err := normalizeConsole(console) if err != nil { return false } for i := range f.Credentials { if f.Credentials[i].Console == want { f.Credentials = append(f.Credentials[:i], f.Credentials[i+1:]...) return true } } return false } // pick resolves which credential a verb should use: an explicit --console, // else CARLOS_CONSOLE, else the configured default (.carlos/config walking // up from cwd, then ~/.carlos/config — config.go), else the only live // credential. Several with no default is a refusal rather than a guess — // shipping to the wrong deployment because the CLI picked one for you is // not a mistake worth being convenient about. // // Any explicitly named console — flag, env, or config — is authoritative: // it resolves against credentials for THAT console or errors. It never // falls back to "the only credential", because inside a checkout pinned to // one stack, quietly using a credential for a different stack is the wrong // deployment with extra steps. func (f *credentialFile) pick(console string) (*credential, error) { if console == "" { console = os.Getenv("CARLOS_CONSOLE") } from := "" if console == "" { var src string var err error console, src, err = configDefault() if err != nil { return nil, err } if console != "" { // Only a config-sourced console gets the clause: whoever typed // --console or exported CARLOS_CONSOLE knows where it came from, // but a default set weeks ago in a file needs naming. from = fmt.Sprintf(" (the default from %s)", src) } } if console != "" { want, err := normalizeConsole(console) if err != nil { return nil, err } c := f.find(want) if c == nil { return nil, ¬LoggedInError{Console: want, msg: fmt.Sprintf("not logged in to %s%s — run: carlos auth login --console %s", want, from, want)} } if c.Expired(time.Now()) { return nil, ¬LoggedInError{Console: want, Expired: true, msg: fmt.Sprintf("the credential for %s%s expired %s — run: carlos auth login --console %s", want, from, c.ExpiresAt.Format(time.RFC3339), want)} } return c, nil } var live []*credential for i := range f.Credentials { if !f.Credentials[i].Expired(time.Now()) { live = append(live, &f.Credentials[i]) } } switch len(live) { case 1: return live[0], nil case 0: // EXACT wording — apimode.go's noCredentialsMsg matches this string. return nil, errors.New("not logged in — run: carlos auth login --console ") default: var names []string for _, c := range live { names = append(names, c.Console) } return nil, fmt.Errorf("logged in to several consoles — say which with --console, "+ "or set a default with `carlos auth default `: %s", strings.Join(names, ", ")) } } // notLoggedInError is pick's refusal when a NAMED console has no live // credential — typed so resolveMode's interactive path (#162) can see // which console to offer a login for, while Error() stays byte-identical // to the strings scripts have matched since the credential file existed. type notLoggedInError struct { Console string Expired bool msg string } func (e *notLoggedInError) Error() string { return e.msg } // credentialPermWarning reports a credentials file other users can read. // A warning rather than a refusal: locking someone out of their own tooling // over a mode bit helps nobody, but saying nothing lets a world-readable // bearer token sit there unnoticed. func credentialPermWarning() string { path := credentialsPath() fi, err := os.Stat(path) if err != nil { return "" } if fi.Mode().Perm()&0o077 == 0 { return "" } return fmt.Sprintf("warning: %s is mode %04o — it holds a bearer token. Fix with: chmod 600 %s", path, fi.Mode().Perm(), path) } // normalizeConsole turns whatever was typed into the origin the console calls // itself (its OAuth client_id): scheme + host, no trailing slash, no path. func normalizeConsole(in string) (string, error) { in = strings.TrimSpace(in) if in == "" { return "", errors.New("no console URL") } u, err := url.Parse(in) if err != nil { return "", fmt.Errorf("%q is not a URL: %w", in, err) } if u.Scheme != "https" && u.Scheme != "http" { return "", fmt.Errorf("%q needs an https:// (or http:// for a local console) URL", in) } if u.Host == "" { return "", fmt.Errorf("%q has no host", in) } // http is for a console on this machine and nowhere else. Over http the // login flow hands a 90-day bearer token to anyone on the path, and a // typo'd or attacker-supplied http:// origin is a credential posted in // clear text to a stranger. if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) { return "", fmt.Errorf("%q is http:// but not local — a login token would cross the network in clear text. Use https://", in) } return u.Scheme + "://" + u.Host, nil } // isLoopbackHost reports whether a URL host is this machine. func isLoopbackHost(host string) bool { if host == "localhost" { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } // ---- talking to the console ------------------------------------------------- // consoleClient is the CLI's half of the console's JSON API. type consoleClient struct { base string token string httpc *http.Client } func newConsoleClient(base, token string) *consoleClient { // Redirects are NEVER followed (#160): Go's default policy follows a // cross-host GET with Authorization stripped (a mystery 401 from the // new host) and surfaces unreplayable POSTs as raw 3xx — both wrong. // Refusing uniformly lets every method see the 3xx + Location, which // consoleMoved (apimode.go) turns into an explanation and an offer. return &consoleClient{base: base, token: token, httpc: &http.Client{ Timeout: 30 * time.Second, CheckRedirect: neverFollow, }} } // do runs one JSON round-trip. A non-200 comes back carrying the console's // own {"error": …} text, because that text is written for the person at the // terminal. func (c *consoleClient) do(ctx context.Context, method, path string, in, out any) (int, error) { var body io.Reader if in != nil { raw, err := json.Marshal(in) if err != nil { return 0, err } body = bytes.NewReader(raw) } req, err := http.NewRequestWithContext(ctx, method, c.base+path, body) if err != nil { return 0, err } if in != nil { req.Header.Set("Content-Type", "application/json") } if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) } res, err := c.httpc.Do(req) if err != nil { return 0, err } defer res.Body.Close() if merr := consoleMoved(c.base, res.StatusCode, res.Header.Get("Location")); merr != nil { return res.StatusCode, offerConsoleMoved(merr) } raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return res.StatusCode, err } if res.StatusCode != http.StatusOK { var e struct { Error string `json:"error"` } msg := res.Status if json.Unmarshal(raw, &e) == nil && e.Error != "" { msg = e.Error } return res.StatusCode, fmt.Errorf("%s", msg) } if out != nil { if err := json.Unmarshal(raw, out); err != nil { return res.StatusCode, fmt.Errorf("%s %s: unreadable response: %w", method, path, err) } } return res.StatusCode, nil } // shipHTTPClientTimeout is the ABSOLUTE per-request Timeout doMultipart's own // client carries — zero (the production default) means none: a ship relies // solely on its caller's context deadline (apimode.go's ship gives itself a // generous 15 minutes), because the console's own budget for a big upload — // up to 10 minutes to read the body, up to 2 more to store it // (internal/console/apicli.go) — already exceeds the 30s absolute Timeout // c.httpc carries for the JSON verbs. Reusing that client here would abort a // large, still-healthy upload CLIENT-side while the server was still working // on it, reporting a live ship as failed. // // A var only so a test can shrink it far below what any real upload takes — // set it small against a deliberately slow server and the request fails // fast, proving the failure mode the zero-Timeout client is built to avoid, // without an actual multi-minute sleep to demonstrate it. var shipHTTPClientTimeout time.Duration // doMultipart runs one multipart/form-data POST, streaming the body through // an io.Pipe rather than buffering it whole: write feeds the multipart.Writer // from a goroutine while http.Client reads the other end of the pipe as it // sends the request, so a 30MiB artifact is never held twice over (once in // caller memory, once again in an http.Client-owned buffer). // // Unlike do(), a non-200 status here is NOT turned into an error — the raw // body comes back alongside the status so a caller that needs more than a // bare {"error"} string (a stable-refusal's console_url, ship()'s per- // artifact result) can decode it itself. func (c *consoleClient) doMultipart(ctx context.Context, path string, write func(*multipart.Writer) error) (int, []byte, error) { return c.doMultipartMethod(ctx, http.MethodPost, path, write) } // doMultipartMethod is doMultipart over any method: `carlos system update`'s // local-binary upload goes to a PUT door (PUT /api/cli/system/upload), and // Go's ServeMux matches the method as part of the pattern, so a POST there // is a 405 rather than an upload. Everything else — the pipe, the client and // the raw non-2xx body — is doMultipart's, unchanged and not a second copy. func (c *consoleClient) doMultipartMethod(ctx context.Context, method, path string, write func(*multipart.Writer) error) (int, []byte, error) { return c.doMultipartHeaders(ctx, method, path, nil, write) } // doMultipartHeaders is doMultipartMethod with extra request headers, such // as a run's Idempotency-Key. They are set before the content type and the // bearer, which they cannot override. func (c *consoleClient) doMultipartHeaders(ctx context.Context, method, path string, headers http.Header, write func(*multipart.Writer) error) (int, []byte, error) { pr, pw := io.Pipe() mw := multipart.NewWriter(pw) go func() { err := write(mw) if err == nil { err = mw.Close() } pw.CloseWithError(err) }() req, err := http.NewRequestWithContext(ctx, method, c.base+path, pr) if err != nil { // The write goroutine above is already running (or about to run) and // will otherwise block forever trying to write to a pipe nobody ever // reads: closing our end unblocks it with io.ErrClosedPipe instead of // leaking it. pr.Close() return 0, nil, err } for k, vs := range headers { req.Header[k] = vs } req.Header.Set("Content-Type", mw.FormDataContentType()) if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) } // A client of its own — see shipHTTPClientTimeout above — never c.httpc, // whose 30s absolute Timeout is sized for the JSON verbs, not a large // upload. Same no-redirect rule as c.httpc, same reason. client := &http.Client{Timeout: shipHTTPClientTimeout, CheckRedirect: neverFollow} res, err := client.Do(req) if err != nil { return 0, nil, err } defer res.Body.Close() if merr := consoleMoved(c.base, res.StatusCode, res.Header.Get("Location")); merr != nil { return res.StatusCode, nil, offerConsoleMoved(merr) } body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return res.StatusCode, nil, err } return res.StatusCode, body, nil } // doStream runs one request whose response body is a STREAM — `carlos db` // reading a replica file down — and hands the response back unread, so the // caller copies the body where it is going without holding it whole. // header carries the caller's extras (a Range) on top of the bearer. // // Same client shape as doMultipart, for the same reason: no absolute // Timeout (the caller's context is the budget, and a large file over a slow // link outlives the JSON client's 30s), and no redirects. A non-2xx status // is NOT an error here: the caller reads the body itself. The caller closes // the response body. func (c *consoleClient) doStream(ctx context.Context, method, path string, body io.Reader, contentLength int64, header http.Header) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, method, c.base+path, body) if err != nil { return nil, err } if body != nil && contentLength >= 0 { req.ContentLength = contentLength } for k, vs := range header { for _, v := range vs { req.Header.Add(k, v) } } if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) } client := &http.Client{Timeout: shipHTTPClientTimeout, CheckRedirect: neverFollow} res, err := client.Do(req) if err != nil { return nil, err } if merr := consoleMoved(c.base, res.StatusCode, res.Header.Get("Location")); merr != nil { res.Body.Close() return nil, offerConsoleMoved(merr) } return res, nil } // neverFollow is the one redirect policy every console client uses. func neverFollow(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } type startResponse struct { UserCode string `json:"user_code"` DeviceCode string `json:"device_code"` VerifyURI string `json:"verification_uri"` VerifyURIFull string `json:"verification_uri_complete"` Interval int `json:"interval"` ExpiresIn int `json:"expires_in"` } type pollResponse struct { Status string `json:"status"` Token string `json:"token"` Address string `json:"address"` ExpiresAt string `json:"expires_at"` Interval int `json:"interval"` } type whoamiResponse struct { Address string `json:"address"` Console string `json:"console"` Label string `json:"label"` ExpiresAt string `json:"expires_at"` } // ---- verbs ------------------------------------------------------------------ // defaultLabel is what this terminal calls itself on the console's // command-line access page: user@host, so a person with three machines can // tell which token to revoke. func defaultLabel() string { name := "someone" if u, err := user.Current(); err == nil && u.Username != "" { name = u.Username } host, err := os.Hostname() if err != nil || host == "" { host = "unknown-host" } return name + "@" + host } // Bounds on what a console can talk this process into doing. It is a console // we are about to trust with our identity, so this is not a threat model so // much as a refusal to turn two JSON fields into a CPU burner: a zero interval // is a hot loop, and an enormous expires_in is a hot loop that never ends. const ( minPollWait = time.Second maxPollWait = 30 * time.Second maxLoginWait = 15 * time.Minute ) // pollWait bounds the sleep between polls, floor included: interval 0 must // never mean "spin". func pollWait(seconds int) time.Duration { d := time.Duration(seconds) * time.Second if d < minPollWait { return minPollWait } if d > maxPollWait { return maxPollWait } return d } // loginDeadline bounds how long the CLI waits overall, whatever expires_in says. func loginDeadline(now time.Time, expiresIn int) time.Time { d := time.Duration(expiresIn) * time.Second if d <= 0 || d > maxLoginWait { d = maxLoginWait } return now.Add(d) } // loginConsole is login's resolution chain: flag → CARLOS_CONSOLE → the // configured default → the flagship. Login alone gets the built-in landing // place: there may be no credential yet to pick from, and the flagship is // the product answer. Every other verb resolves through pick(), which // assumes nothing. func loginConsole(flagValue string) (string, error) { base := flagValue if base == "" { base = os.Getenv("CARLOS_CONSOLE") } if base == "" { var err error if base, _, err = configDefault(); err != nil { return "", err } } if base == "" { base = defaultConsole } return normalizeConsole(base) } func authLogin(out io.Writer, args []string) error { fs := flag.NewFlagSet("auth login", flag.ContinueOnError) fs.SetOutput(out) console := consoleOption(fs, "the console to log in to (default: the configured default, else "+defaultConsole+")") label := fs.String("label", "", "what to call this terminal on the console (default: user@host)") fs.Usage = flagUsage(fs, "") if err := fs.Parse(args); err != nil { return err } base, err := loginConsole(*console) if err != nil { return err } if *label == "" { *label = defaultLabel() } ctx := context.Background() c := newConsoleClient(base, "") var start startResponse if _, err := c.do(ctx, http.MethodPost, "/auth/cli/start", map[string]string{"label": *label}, &start); err != nil { return fmt.Errorf("starting the login at %s: %w", base, err) } if start.DeviceCode == "" || start.UserCode == "" { return fmt.Errorf("%s did not return a login code — is that a CARLOS console?", base) } // The BARE verification URI plus the code, never the ready-made // ?code=… link, even though the console offers one. // // A link that lands one click from an Approve button is the whole // device-code phishing attack: an attacker starts this flow, sends you // their link, and one click hands them a 90-day credential that acts as // you. The console refuses to approve from a prefilled code for that // reason, and printing the complete URL here would teach people that // following such a link is the normal way to do this — which is exactly // the habit the attack needs. Type the code. link := start.VerifyURI if link == "" { link = base + "/auth/cli" } // Printed, never opened: this frequently runs over SSH, where opening a // browser would open it on the wrong machine. pal := colorFor(out) fmt.Fprintf(out, "\nOpen this in a browser signed in to %s:\n\n %s\n\n", base, pal.link(link)) // The code is the one thing here a person has to read off the screen and // type somewhere else, so it is the one thing given weight. fmt.Fprintf(out, "and type this code:\n\n %s\n\n", pal.head(start.UserCode)) mutedf(out, "Waiting for approval (this code expires in %d minutes)…", start.ExpiresIn/60) deadline := loginDeadline(time.Now(), start.ExpiresIn) wait := pollWait(start.Interval) for time.Now().Before(deadline) { time.Sleep(wait) var poll pollResponse if _, err := c.do(ctx, http.MethodPost, "/auth/cli/poll", map[string]string{"device_code": start.DeviceCode}, &poll); err != nil { // A hiccup mid-poll is not a failed login: keep waiting, the // human is mid-approval. continue } if poll.Interval > 0 { wait = pollWait(poll.Interval) } switch poll.Status { case "pending": continue case "denied": return errors.New("the console says that login was denied — nothing was issued") case "expired": return errors.New("that login code expired before it was approved — run this again") case "unknown": return errors.New("the console does not recognise this login — run this again") case "approved": if poll.Token == "" { return errors.New("the console approved the login but returned no token") } now := time.Now().UTC() expires := now.Add(90 * 24 * time.Hour) if poll.ExpiresAt != "" { if t, err := time.Parse(time.RFC3339, poll.ExpiresAt); err == nil { expires = t } } f, err := loadCredentials() if err != nil { return err } f.put(credential{Console: base, Address: poll.Address, Token: poll.Token, Label: *label, CreatedAt: now, ExpiresAt: expires}) if err := f.save(); err != nil { return fmt.Errorf("storing the credential: %w", err) } // Checked here as well as in whoami: this is the moment a bearer // token first exists on this machine, and a pre-existing file with // loose permissions is exactly when to say so. if warn := credentialPermWarning(); warn != "" { warnf(out, "%s", warn) } fmt.Fprintf(out, "\n%s to %s as %s.\n", pal.ok("Logged in"), base, poll.Address) mutedf(out, "Token stored in %s (mode 0600); it expires %s.", credentialsPath(), expires.Format("2006-01-02")) mutedf(out, "Revoke it any time at %s/auth/cli/tokens", base) return nil default: return fmt.Errorf("the console answered with an unknown status %q", poll.Status) } } return errors.New("gave up waiting for approval — run this again when you're at a browser") } func authWhoami(out io.Writer, args []string) error { fs := flag.NewFlagSet("auth whoami", flag.ContinueOnError) fs.SetOutput(out) console := consoleOption(fs, "which console (only needed with several)") fs.Usage = flagUsage(fs, "") if err := fs.Parse(args); err != nil { return err } f, err := loadCredentials() if err != nil { return err } c, err := f.pick(*console) if err != nil { return err } if warn := credentialPermWarning(); warn != "" { warnf(out, "%s", warn) } var who whoamiResponse if _, err := newConsoleClient(c.Console, c.Token). do(context.Background(), http.MethodGet, "/auth/cli/whoami", nil, &who); err != nil { // Deliberately shows the STORED identity as well: "the console says // no" and "I could not reach the console" are different problems, and // a person debugging one needs to know which they have. return fmt.Errorf("%s says: %v (this machine holds a credential for %s)", c.Console, err, c.Address) } fmt.Fprintf(out, "%s at %s\n", who.Address, c.Console) if who.Label != "" { mutedf(out, " terminal: %s", who.Label) } if who.ExpiresAt != "" { mutedf(out, " expires: %s", who.ExpiresAt) } // Memberships are a bonus line, not part of whoami's contract: identity // already printed, and a console from before GET /api/cli/apps (or a // blip fetching it) must not make whoami look broken — so ANY error // here degrades to silence rather than a message, and the moved-console // OFFER is suppressed too: a bonus line must never turn into a prompt. quietConsoleOffers = true defer func() { quietConsoleOffers = false }() var acc struct { Accounts []apiAccountEntry `json:"accounts"` } if _, err := newConsoleClient(c.Console, c.Token). do(context.Background(), http.MethodGet, "/api/cli/apps", nil, &acc); err == nil && len(acc.Accounts) > 0 { rows := make([]string, 0, len(acc.Accounts)) for _, a := range acc.Accounts { rows = append(rows, fmt.Sprintf("%s (%s)", a.Name, a.Sqid)) } mutedf(out, " accounts: %s", strings.Join(rows, ", ")) } return nil } func authLogout(out io.Writer, args []string) error { fs := flag.NewFlagSet("auth logout", flag.ContinueOnError) fs.SetOutput(out) console := consoleOption(fs, "which console (only needed with several)") fs.Usage = flagUsage(fs, "") if err := fs.Parse(args); err != nil { return err } f, err := loadCredentials() if err != nil { return err } c, err := f.pick(*console) if err != nil { return err } origin := c.Console // Revoke first, then forget. If the console cannot be reached the local // copy still goes — but say so, because a token this machine has stopped // tracking is still a live credential until it expires, and the person // needs to know to kill it from the console. revokeErr := func() error { _, err := newConsoleClient(origin, c.Token). do(context.Background(), http.MethodPost, "/auth/cli/revoke", map[string]string{}, nil) return err }() f.remove(origin) if err := f.save(); err != nil { return err } if revokeErr != nil { warnf(out, "Forgot the credential for %s locally, but could not revoke it there: %v", origin, revokeErr) warnf(out, "It stays live until it expires — revoke it at %s/auth/cli/tokens", origin) return nil } fmt.Fprintf(out, "%s of %s — the token is revoked.\n", colorFor(out).ok("Logged out"), origin) return nil } // authDefault shows or sets the default console. Bare: report the effective // configured default and which file said so (flag and env are not consulted // — this verb reports CONFIG, and someone with CARLOS_CONSOLE exported can // see their own shell). With a URL: write ~/.carlos/config, or // ./.carlos/config under --project — relative to the working directory, // deliberately not "the repo root": the CLI has no VCS awareness, and // "run it where you want the file" beats guessing. func authDefault(out io.Writer, args []string) error { fs := flag.NewFlagSet("auth default", flag.ContinueOnError) fs.SetOutput(out) project := fs.Bool("project", false, "write ./.carlos/config (this directory) instead of ~/.carlos/config") destinationOption(fs) // reads, and writes into, that destination's block fs.Usage = flagUsage(fs, "") if err := fs.Parse(args); err != nil { return err } switch fs.NArg() { case 0: if *project { return errors.New("--project sets a default: carlos auth default --project ") } console, src, err := configDefault() if err != nil { return err } if console == "" { fmt.Fprintf(out, "no default console configured.\n") fmt.Fprintf(out, "`carlos auth login` defaults to %s; other commands use the\n", defaultConsole) fmt.Fprintf(out, "only stored credential, or ask when there are several.\n") fmt.Fprintf(out, "Set one with: carlos auth default (--project pins this directory)\n") return nil } if d := configDestination(console); d != "" { src = "the " + d + " destination in " + src } fmt.Fprintf(out, "%s (from %s)\n", console, src) return nil case 1: path, err := globalConfigPath() perm := os.FileMode(0o700) // ~/.carlos also holds credentials if *project { path, err = filepath.Join(".carlos", "config"), nil perm = 0o755 // meant to be committed; nothing secret inside } if err != nil { return err } if err := saveConfigFile(path, fs.Arg(0), perm); err != nil { return err } // Echo the NORMALIZED value: what was saved, not what was typed. console, err := normalizeConsole(fs.Arg(0)) if err != nil { return err } if d := selectedDestination(); d != "" { path = "the " + d + " destination in " + path } fmt.Fprintf(out, "default console: %s (written to %s)\n", console, path) return nil default: return errors.New("usage: carlos auth default [--project] [-d ] []") } } // ---- what other verbs use --------------------------------------------------- // loggedInCredential is the seam every other verb reaches through: the // credential for a console, or nil with the reason. Never fatal — the callers // have their own answer for "not logged in" (usually: name --owner). func loggedInCredential(console string) (*credential, error) { f, err := loadCredentials() if err != nil { return nil, err } return f.pick(console) }