carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The `carlos auth` verb: CLI identity.
2//
3// Before this, the CLI's authority was "whoever holds the release key or can
4// reach the box", and `carlos accounts create` had nobody to name as an
5// account's owner — which produced accounts their own creator could not open
6// in the console. `carlos auth login` gives the command line an identity, and
7// that identity is what owns the accounts it creates.
8//
9// The flow, and why it is shaped this way:
10//
11// carlos auth login --console https://platform.carlosframework.com
12// → POST /auth/cli/start the console mints a short typed code
13// for the human and a long device code
14// for this process
15// → print the URL and the code the human opens it in ANY browser they
16// are signed in to and approves
17// → POST /auth/cli/poll (loop) the console hands the token back down
18// this channel, once
19// → ~/.carlos/credentials mode 0600, one entry per console origin
20//
21// There is deliberately no local callback listener. A browser-redirect flow
22// needs the browser to reach a port on the machine running the CLI, and the
23// machine running the CLI is very often an SSH session on a box or a CI
24// shell where that is simply false. A code you can retype from a phone works
25// everywhere, which is the whole point.
26//
27// The console — not Keymail — is the authority here. It is already a
28// registered Keymail OAuth client and already knows your accounts and roles,
29// so the CLI trusts the deployment the operator actually runs.
30package main
31
32import (
33 "bytes"
34 "context"
35 "encoding/json"
36 "errors"
37 "flag"
38 "fmt"
39 "io"
40 "mime/multipart"
41 "net"
42 "net/http"
43 "net/url"
44 "os"
45 "os/user"
46 "path/filepath"
47 "strings"
48 "time"
49)
50
51const authUsage = `usage: carlos auth <login|whoami|logout|default>
52
53 login [--console <url>] log this terminal in to a deployment's console
54 (default: the configured default console, else
55 https://platform.carlosframework.com). Prints a
56 code; you approve it in a browser that is already
57 signed in to that console. No browser on this
58 machine is needed — the code travels.
59 [--label <name>] what to call this terminal on the console's
60 command-line access page (default: user@host)
61 whoami [--console <url>] ask the console who this terminal is
62 logout [--console <url>] revoke this terminal's token and forget it
63 default [--project] [-d <destination>] [<url>]
64 show the default console and where it is set, or
65 set it: writes ~/.carlos/config — or, with
66 --project, ./.carlos/config, which overrides the
67 global and is meant to be committed so a checkout
68 pins its own deployment. -d reads or writes that
69 destination's console ("carlos config
70 destinations" manages the rest)
71
72The token lands in ~/.carlos/credentials (mode 0600), one entry per console
73origin — a laptop can hold credentials for the flagship and a self-hosted
74deployment at once. --console may be omitted when there is exactly one, or
75when a default is set.
76
77HEADLESS AND BOX-SIDE USE: do not log in. A box has no browser and no human,
78and a long-lived token on it is a standing credential nobody is watching.
79Pass --owner <address> to "carlos accounts create" instead — that is the
80documented path, and it needs no identity of its own.
81`
82
83// RunAuth dispatches the auth sub-verbs.
84func RunAuth(args []string) {
85 if len(args) == 0 {
86 fmt.Fprint(os.Stderr, authUsage)
87 os.Exit(1)
88 }
89 var err error
90 switch args[0] {
91 case "login":
92 err = authLogin(os.Stdout, args[1:])
93 case "whoami":
94 err = authWhoami(os.Stdout, args[1:])
95 case "logout":
96 err = authLogout(os.Stdout, args[1:])
97 case "default":
98 err = authDefault(os.Stdout, args[1:])
99 default:
100 fmt.Fprint(os.Stderr, authUsage)
101 os.Exit(1)
102 }
103 if err != nil {
104 fmt.Fprintf(os.Stderr, "%s %v\n", colorFor(os.Stderr).bad("carlos auth "+args[0]+":"), err)
105 os.Exit(1)
106 }
107}
108
109// ---- the credentials file ---------------------------------------------------
110
111// credential is one console's token. The token is the whole secret: it is
112// never printed, never logged, and the file it lives in is 0600.
113type credential struct {
114 Console string `json:"console"`
115 Address string `json:"address"`
116 Token string `json:"token"`
117 Label string `json:"label,omitempty"`
118 CreatedAt time.Time `json:"created_at"`
119 ExpiresAt time.Time `json:"expires_at"`
120}
121
122// Expired reports whether this credential is past its console-declared life.
123// A zero ExpiresAt is treated as live: a console that declined to say is not
124// the same as one that said "now".
125func (c credential) Expired(now time.Time) bool {
126 return !c.ExpiresAt.IsZero() && now.After(c.ExpiresAt)
127}
128
129// credentialFile is the whole file. A list rather than a map so the on-disk
130// order is stable and a human can read it.
131type credentialFile struct {
132 Version int `json:"version"`
133 Credentials []credential `json:"credentials"`
134}
135
136// credentialsPath is CARLOS_CREDENTIALS, else ~/.carlos/credentials. The env
137// override exists for tests and for anyone keeping their credential somewhere
138// other than $HOME (a per-project shell, a tmpfs).
139func credentialsPath() string {
140 if p := os.Getenv("CARLOS_CREDENTIALS"); p != "" {
141 return p
142 }
143 home, err := os.UserHomeDir()
144 if err != nil {
145 return ".carlos/credentials"
146 }
147 return filepath.Join(home, ".carlos", "credentials")
148}
149
150// loadCredentials reads the file. A missing file is an empty one, not an
151// error: "you are not logged in" is a state, and every caller has to handle
152// it anyway.
153func loadCredentials() (*credentialFile, error) {
154 f := &credentialFile{Version: 1}
155 raw, err := os.ReadFile(credentialsPath())
156 if errors.Is(err, os.ErrNotExist) {
157 return f, nil
158 }
159 if err != nil {
160 return nil, err
161 }
162 if len(bytes.TrimSpace(raw)) == 0 {
163 return f, nil
164 }
165 if err := json.Unmarshal(raw, f); err != nil {
166 return nil, fmt.Errorf("%s is not readable as credentials: %w", credentialsPath(), err)
167 }
168 f.Version = 1
169 return f, nil
170}
171
172// save writes the file 0600, through a temp file in the same directory so a
173// crash mid-write cannot leave a truncated credential store.
174func (f *credentialFile) save() error {
175 path := credentialsPath()
176 dir := filepath.Dir(path)
177 if err := os.MkdirAll(dir, 0o700); err != nil {
178 return err
179 }
180 f.Version = 1
181 body, err := json.MarshalIndent(f, "", " ")
182 if err != nil {
183 return err
184 }
185 body = append(body, '\n')
186 tmp, err := os.CreateTemp(dir, ".credentials-*")
187 if err != nil {
188 return err
189 }
190 defer os.Remove(tmp.Name())
191 // Chmod before the write, not after: the secret must never exist on disk,
192 // even for a moment, at the temp file's default permissions.
193 if err := tmp.Chmod(0o600); err != nil {
194 tmp.Close()
195 return err
196 }
197 if _, err := tmp.Write(body); err != nil {
198 tmp.Close()
199 return err
200 }
201 if err := tmp.Close(); err != nil {
202 return err
203 }
204 return os.Rename(tmp.Name(), path)
205}
206
207// find returns the credential for a console origin, or nil.
208func (f *credentialFile) find(console string) *credential {
209 want, err := normalizeConsole(console)
210 if err != nil {
211 return nil
212 }
213 for i := range f.Credentials {
214 if f.Credentials[i].Console == want {
215 return &f.Credentials[i]
216 }
217 }
218 return nil
219}
220
221// put stores a credential, replacing any existing entry for that console.
222// One entry per origin: logging in again to one deployment must never disturb
223// another.
224func (f *credentialFile) put(c credential) {
225 if n, err := normalizeConsole(c.Console); err == nil {
226 c.Console = n
227 }
228 for i := range f.Credentials {
229 if f.Credentials[i].Console == c.Console {
230 f.Credentials[i] = c
231 return
232 }
233 }
234 f.Credentials = append(f.Credentials, c)
235}
236
237// remove drops a console's credential, reporting whether there was one.
238func (f *credentialFile) remove(console string) bool {
239 want, err := normalizeConsole(console)
240 if err != nil {
241 return false
242 }
243 for i := range f.Credentials {
244 if f.Credentials[i].Console == want {
245 f.Credentials = append(f.Credentials[:i], f.Credentials[i+1:]...)
246 return true
247 }
248 }
249 return false
250}
251
252// pick resolves which credential a verb should use: an explicit --console,
253// else CARLOS_CONSOLE, else the configured default (.carlos/config walking
254// up from cwd, then ~/.carlos/config — config.go), else the only live
255// credential. Several with no default is a refusal rather than a guess —
256// shipping to the wrong deployment because the CLI picked one for you is
257// not a mistake worth being convenient about.
258//
259// Any explicitly named console — flag, env, or config — is authoritative:
260// it resolves against credentials for THAT console or errors. It never
261// falls back to "the only credential", because inside a checkout pinned to
262// one stack, quietly using a credential for a different stack is the wrong
263// deployment with extra steps.
264func (f *credentialFile) pick(console string) (*credential, error) {
265 if console == "" {
266 console = os.Getenv("CARLOS_CONSOLE")
267 }
268 from := ""
269 if console == "" {
270 var src string
271 var err error
272 console, src, err = configDefault()
273 if err != nil {
274 return nil, err
275 }
276 if console != "" {
277 // Only a config-sourced console gets the clause: whoever typed
278 // --console or exported CARLOS_CONSOLE knows where it came from,
279 // but a default set weeks ago in a file needs naming.
280 from = fmt.Sprintf(" (the default from %s)", src)
281 }
282 }
283 if console != "" {
284 want, err := normalizeConsole(console)
285 if err != nil {
286 return nil, err
287 }
288 c := f.find(want)
289 if c == nil {
290 return nil, &notLoggedInError{Console: want,
291 msg: fmt.Sprintf("not logged in to %s%s — run: carlos auth login --console %s", want, from, want)}
292 }
293 if c.Expired(time.Now()) {
294 return nil, &notLoggedInError{Console: want, Expired: true,
295 msg: fmt.Sprintf("the credential for %s%s expired %s — run: carlos auth login --console %s",
296 want, from, c.ExpiresAt.Format(time.RFC3339), want)}
297 }
298 return c, nil
299 }
300 var live []*credential
301 for i := range f.Credentials {
302 if !f.Credentials[i].Expired(time.Now()) {
303 live = append(live, &f.Credentials[i])
304 }
305 }
306 switch len(live) {
307 case 1:
308 return live[0], nil
309 case 0:
310 // EXACT wording — apimode.go's noCredentialsMsg matches this string.
311 return nil, errors.New("not logged in — run: carlos auth login --console <url>")
312 default:
313 var names []string
314 for _, c := range live {
315 names = append(names, c.Console)
316 }
317 return nil, fmt.Errorf("logged in to several consoles — say which with --console, "+
318 "or set a default with `carlos auth default <url>`: %s",
319 strings.Join(names, ", "))
320 }
321}
322
323// notLoggedInError is pick's refusal when a NAMED console has no live
324// credential — typed so resolveMode's interactive path (#162) can see
325// which console to offer a login for, while Error() stays byte-identical
326// to the strings scripts have matched since the credential file existed.
327type notLoggedInError struct {
328 Console string
329 Expired bool
330 msg string
331}
332
333func (e *notLoggedInError) Error() string { return e.msg }
334
335// credentialPermWarning reports a credentials file other users can read.
336// A warning rather than a refusal: locking someone out of their own tooling
337// over a mode bit helps nobody, but saying nothing lets a world-readable
338// bearer token sit there unnoticed.
339func credentialPermWarning() string {
340 path := credentialsPath()
341 fi, err := os.Stat(path)
342 if err != nil {
343 return ""
344 }
345 if fi.Mode().Perm()&0o077 == 0 {
346 return ""
347 }
348 return fmt.Sprintf("warning: %s is mode %04o — it holds a bearer token. Fix with: chmod 600 %s",
349 path, fi.Mode().Perm(), path)
350}
351
352// normalizeConsole turns whatever was typed into the origin the console calls
353// itself (its OAuth client_id): scheme + host, no trailing slash, no path.
354func normalizeConsole(in string) (string, error) {
355 in = strings.TrimSpace(in)
356 if in == "" {
357 return "", errors.New("no console URL")
358 }
359 u, err := url.Parse(in)
360 if err != nil {
361 return "", fmt.Errorf("%q is not a URL: %w", in, err)
362 }
363 if u.Scheme != "https" && u.Scheme != "http" {
364 return "", fmt.Errorf("%q needs an https:// (or http:// for a local console) URL", in)
365 }
366 if u.Host == "" {
367 return "", fmt.Errorf("%q has no host", in)
368 }
369 // http is for a console on this machine and nowhere else. Over http the
370 // login flow hands a 90-day bearer token to anyone on the path, and a
371 // typo'd or attacker-supplied http:// origin is a credential posted in
372 // clear text to a stranger.
373 if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) {
374 return "", fmt.Errorf("%q is http:// but not local — a login token would cross the network in clear text. Use https://", in)
375 }
376 return u.Scheme + "://" + u.Host, nil
377}
378
379// isLoopbackHost reports whether a URL host is this machine.
380func isLoopbackHost(host string) bool {
381 if host == "localhost" {
382 return true
383 }
384 ip := net.ParseIP(host)
385 return ip != nil && ip.IsLoopback()
386}
387
388// ---- talking to the console -------------------------------------------------
389
390// consoleClient is the CLI's half of the console's JSON API.
391type consoleClient struct {
392 base string
393 token string
394 httpc *http.Client
395}
396
397func newConsoleClient(base, token string) *consoleClient {
398 // Redirects are NEVER followed (#160): Go's default policy follows a
399 // cross-host GET with Authorization stripped (a mystery 401 from the
400 // new host) and surfaces unreplayable POSTs as raw 3xx — both wrong.
401 // Refusing uniformly lets every method see the 3xx + Location, which
402 // consoleMoved (apimode.go) turns into an explanation and an offer.
403 return &consoleClient{base: base, token: token, httpc: &http.Client{
404 Timeout: 30 * time.Second,
405 CheckRedirect: neverFollow,
406 }}
407}
408
409// do runs one JSON round-trip. A non-200 comes back carrying the console's
410// own {"error": …} text, because that text is written for the person at the
411// terminal.
412func (c *consoleClient) do(ctx context.Context, method, path string, in, out any) (int, error) {
413 var body io.Reader
414 if in != nil {
415 raw, err := json.Marshal(in)
416 if err != nil {
417 return 0, err
418 }
419 body = bytes.NewReader(raw)
420 }
421 req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
422 if err != nil {
423 return 0, err
424 }
425 if in != nil {
426 req.Header.Set("Content-Type", "application/json")
427 }
428 if c.token != "" {
429 req.Header.Set("Authorization", "Bearer "+c.token)
430 }
431 res, err := c.httpc.Do(req)
432 if err != nil {
433 return 0, err
434 }
435 defer res.Body.Close()
436 if merr := consoleMoved(c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
437 return res.StatusCode, offerConsoleMoved(merr)
438 }
439 raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
440 if err != nil {
441 return res.StatusCode, err
442 }
443 if res.StatusCode != http.StatusOK {
444 var e struct {
445 Error string `json:"error"`
446 }
447 msg := res.Status
448 if json.Unmarshal(raw, &e) == nil && e.Error != "" {
449 msg = e.Error
450 }
451 return res.StatusCode, fmt.Errorf("%s", msg)
452 }
453 if out != nil {
454 if err := json.Unmarshal(raw, out); err != nil {
455 return res.StatusCode, fmt.Errorf("%s %s: unreadable response: %w", method, path, err)
456 }
457 }
458 return res.StatusCode, nil
459}
460
461// shipHTTPClientTimeout is the ABSOLUTE per-request Timeout doMultipart's own
462// client carries — zero (the production default) means none: a ship relies
463// solely on its caller's context deadline (apimode.go's ship gives itself a
464// generous 15 minutes), because the console's own budget for a big upload —
465// up to 10 minutes to read the body, up to 2 more to store it
466// (internal/console/apicli.go) — already exceeds the 30s absolute Timeout
467// c.httpc carries for the JSON verbs. Reusing that client here would abort a
468// large, still-healthy upload CLIENT-side while the server was still working
469// on it, reporting a live ship as failed.
470//
471// A var only so a test can shrink it far below what any real upload takes —
472// set it small against a deliberately slow server and the request fails
473// fast, proving the failure mode the zero-Timeout client is built to avoid,
474// without an actual multi-minute sleep to demonstrate it.
475var shipHTTPClientTimeout time.Duration
476
477// doMultipart runs one multipart/form-data POST, streaming the body through
478// an io.Pipe rather than buffering it whole: write feeds the multipart.Writer
479// from a goroutine while http.Client reads the other end of the pipe as it
480// sends the request, so a 30MiB artifact is never held twice over (once in
481// caller memory, once again in an http.Client-owned buffer).
482//
483// Unlike do(), a non-200 status here is NOT turned into an error — the raw
484// body comes back alongside the status so a caller that needs more than a
485// bare {"error"} string (a stable-refusal's console_url, ship()'s per-
486// artifact result) can decode it itself.
487func (c *consoleClient) doMultipart(ctx context.Context, path string, write func(*multipart.Writer) error) (int, []byte, error) {
488 return c.doMultipartMethod(ctx, http.MethodPost, path, write)
489}
490
491// doMultipartMethod is doMultipart over any method: `carlos system update`'s
492// local-binary upload goes to a PUT door (PUT /api/cli/system/upload), and
493// Go's ServeMux matches the method as part of the pattern, so a POST there
494// is a 405 rather than an upload. Everything else — the pipe, the client and
495// the raw non-2xx body — is doMultipart's, unchanged and not a second copy.
496func (c *consoleClient) doMultipartMethod(ctx context.Context, method, path string, write func(*multipart.Writer) error) (int, []byte, error) {
497 return c.doMultipartHeaders(ctx, method, path, nil, write)
498}
499
500// doMultipartHeaders is doMultipartMethod with extra request headers, such
501// as a run's Idempotency-Key. They are set before the content type and the
502// bearer, which they cannot override.
503func (c *consoleClient) doMultipartHeaders(ctx context.Context, method, path string, headers http.Header, write func(*multipart.Writer) error) (int, []byte, error) {
504 pr, pw := io.Pipe()
505 mw := multipart.NewWriter(pw)
506 go func() {
507 err := write(mw)
508 if err == nil {
509 err = mw.Close()
510 }
511 pw.CloseWithError(err)
512 }()
513 req, err := http.NewRequestWithContext(ctx, method, c.base+path, pr)
514 if err != nil {
515 // The write goroutine above is already running (or about to run) and
516 // will otherwise block forever trying to write to a pipe nobody ever
517 // reads: closing our end unblocks it with io.ErrClosedPipe instead of
518 // leaking it.
519 pr.Close()
520 return 0, nil, err
521 }
522 for k, vs := range headers {
523 req.Header[k] = vs
524 }
525 req.Header.Set("Content-Type", mw.FormDataContentType())
526 if c.token != "" {
527 req.Header.Set("Authorization", "Bearer "+c.token)
528 }
529 // A client of its own — see shipHTTPClientTimeout above — never c.httpc,
530 // whose 30s absolute Timeout is sized for the JSON verbs, not a large
531 // upload. Same no-redirect rule as c.httpc, same reason.
532 client := &http.Client{Timeout: shipHTTPClientTimeout, CheckRedirect: neverFollow}
533 res, err := client.Do(req)
534 if err != nil {
535 return 0, nil, err
536 }
537 defer res.Body.Close()
538 if merr := consoleMoved(c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
539 return res.StatusCode, nil, offerConsoleMoved(merr)
540 }
541 body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
542 if err != nil {
543 return res.StatusCode, nil, err
544 }
545 return res.StatusCode, body, nil
546}
547
548// doStream runs one request whose response body is a STREAM — `carlos db`
549// reading a replica file down — and hands the response back unread, so the
550// caller copies the body where it is going without holding it whole.
551// header carries the caller's extras (a Range) on top of the bearer.
552//
553// Same client shape as doMultipart, for the same reason: no absolute
554// Timeout (the caller's context is the budget, and a large file over a slow
555// link outlives the JSON client's 30s), and no redirects. A non-2xx status
556// is NOT an error here: the caller reads the body itself. The caller closes
557// the response body.
558func (c *consoleClient) doStream(ctx context.Context, method, path string, body io.Reader, contentLength int64, header http.Header) (*http.Response, error) {
559 req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
560 if err != nil {
561 return nil, err
562 }
563 if body != nil && contentLength >= 0 {
564 req.ContentLength = contentLength
565 }
566 for k, vs := range header {
567 for _, v := range vs {
568 req.Header.Add(k, v)
569 }
570 }
571 if c.token != "" {
572 req.Header.Set("Authorization", "Bearer "+c.token)
573 }
574 client := &http.Client{Timeout: shipHTTPClientTimeout, CheckRedirect: neverFollow}
575 res, err := client.Do(req)
576 if err != nil {
577 return nil, err
578 }
579 if merr := consoleMoved(c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
580 res.Body.Close()
581 return nil, offerConsoleMoved(merr)
582 }
583 return res, nil
584}
585
586// neverFollow is the one redirect policy every console client uses.
587func neverFollow(*http.Request, []*http.Request) error {
588 return http.ErrUseLastResponse
589}
590
591type startResponse struct {
592 UserCode string `json:"user_code"`
593 DeviceCode string `json:"device_code"`
594 VerifyURI string `json:"verification_uri"`
595 VerifyURIFull string `json:"verification_uri_complete"`
596 Interval int `json:"interval"`
597 ExpiresIn int `json:"expires_in"`
598}
599
600type pollResponse struct {
601 Status string `json:"status"`
602 Token string `json:"token"`
603 Address string `json:"address"`
604 ExpiresAt string `json:"expires_at"`
605 Interval int `json:"interval"`
606}
607
608type whoamiResponse struct {
609 Address string `json:"address"`
610 Console string `json:"console"`
611 Label string `json:"label"`
612 ExpiresAt string `json:"expires_at"`
613}
614
615// ---- verbs ------------------------------------------------------------------
616
617// defaultLabel is what this terminal calls itself on the console's
618// command-line access page: user@host, so a person with three machines can
619// tell which token to revoke.
620func defaultLabel() string {
621 name := "someone"
622 if u, err := user.Current(); err == nil && u.Username != "" {
623 name = u.Username
624 }
625 host, err := os.Hostname()
626 if err != nil || host == "" {
627 host = "unknown-host"
628 }
629 return name + "@" + host
630}
631
632// Bounds on what a console can talk this process into doing. It is a console
633// we are about to trust with our identity, so this is not a threat model so
634// much as a refusal to turn two JSON fields into a CPU burner: a zero interval
635// is a hot loop, and an enormous expires_in is a hot loop that never ends.
636const (
637 minPollWait = time.Second
638 maxPollWait = 30 * time.Second
639 maxLoginWait = 15 * time.Minute
640)
641
642// pollWait bounds the sleep between polls, floor included: interval 0 must
643// never mean "spin".
644func pollWait(seconds int) time.Duration {
645 d := time.Duration(seconds) * time.Second
646 if d < minPollWait {
647 return minPollWait
648 }
649 if d > maxPollWait {
650 return maxPollWait
651 }
652 return d
653}
654
655// loginDeadline bounds how long the CLI waits overall, whatever expires_in says.
656func loginDeadline(now time.Time, expiresIn int) time.Time {
657 d := time.Duration(expiresIn) * time.Second
658 if d <= 0 || d > maxLoginWait {
659 d = maxLoginWait
660 }
661 return now.Add(d)
662}
663
664// loginConsole is login's resolution chain: flag → CARLOS_CONSOLE → the
665// configured default → the flagship. Login alone gets the built-in landing
666// place: there may be no credential yet to pick from, and the flagship is
667// the product answer. Every other verb resolves through pick(), which
668// assumes nothing.
669func loginConsole(flagValue string) (string, error) {
670 base := flagValue
671 if base == "" {
672 base = os.Getenv("CARLOS_CONSOLE")
673 }
674 if base == "" {
675 var err error
676 if base, _, err = configDefault(); err != nil {
677 return "", err
678 }
679 }
680 if base == "" {
681 base = defaultConsole
682 }
683 return normalizeConsole(base)
684}
685
686func authLogin(out io.Writer, args []string) error {
687 fs := flag.NewFlagSet("auth login", flag.ContinueOnError)
688 fs.SetOutput(out)
689 console := consoleOption(fs, "the console to log in to (default: the configured default, else "+defaultConsole+")")
690 label := fs.String("label", "", "what to call this terminal on the console (default: user@host)")
691 fs.Usage = flagUsage(fs, "")
692 if err := fs.Parse(args); err != nil {
693 return err
694 }
695 base, err := loginConsole(*console)
696 if err != nil {
697 return err
698 }
699 if *label == "" {
700 *label = defaultLabel()
701 }
702 ctx := context.Background()
703 c := newConsoleClient(base, "")
704 var start startResponse
705 if _, err := c.do(ctx, http.MethodPost, "/auth/cli/start", map[string]string{"label": *label}, &start); err != nil {
706 return fmt.Errorf("starting the login at %s: %w", base, err)
707 }
708 if start.DeviceCode == "" || start.UserCode == "" {
709 return fmt.Errorf("%s did not return a login code — is that a CARLOS console?", base)
710 }
711 // The BARE verification URI plus the code, never the ready-made
712 // ?code=… link, even though the console offers one.
713 //
714 // A link that lands one click from an Approve button is the whole
715 // device-code phishing attack: an attacker starts this flow, sends you
716 // their link, and one click hands them a 90-day credential that acts as
717 // you. The console refuses to approve from a prefilled code for that
718 // reason, and printing the complete URL here would teach people that
719 // following such a link is the normal way to do this — which is exactly
720 // the habit the attack needs. Type the code.
721 link := start.VerifyURI
722 if link == "" {
723 link = base + "/auth/cli"
724 }
725 // Printed, never opened: this frequently runs over SSH, where opening a
726 // browser would open it on the wrong machine.
727 pal := colorFor(out)
728 fmt.Fprintf(out, "\nOpen this in a browser signed in to %s:\n\n %s\n\n", base, pal.link(link))
729 // The code is the one thing here a person has to read off the screen and
730 // type somewhere else, so it is the one thing given weight.
731 fmt.Fprintf(out, "and type this code:\n\n %s\n\n", pal.head(start.UserCode))
732 mutedf(out, "Waiting for approval (this code expires in %d minutes)…", start.ExpiresIn/60)
733
734 deadline := loginDeadline(time.Now(), start.ExpiresIn)
735 wait := pollWait(start.Interval)
736 for time.Now().Before(deadline) {
737 time.Sleep(wait)
738 var poll pollResponse
739 if _, err := c.do(ctx, http.MethodPost, "/auth/cli/poll",
740 map[string]string{"device_code": start.DeviceCode}, &poll); err != nil {
741 // A hiccup mid-poll is not a failed login: keep waiting, the
742 // human is mid-approval.
743 continue
744 }
745 if poll.Interval > 0 {
746 wait = pollWait(poll.Interval)
747 }
748 switch poll.Status {
749 case "pending":
750 continue
751 case "denied":
752 return errors.New("the console says that login was denied — nothing was issued")
753 case "expired":
754 return errors.New("that login code expired before it was approved — run this again")
755 case "unknown":
756 return errors.New("the console does not recognise this login — run this again")
757 case "approved":
758 if poll.Token == "" {
759 return errors.New("the console approved the login but returned no token")
760 }
761 now := time.Now().UTC()
762 expires := now.Add(90 * 24 * time.Hour)
763 if poll.ExpiresAt != "" {
764 if t, err := time.Parse(time.RFC3339, poll.ExpiresAt); err == nil {
765 expires = t
766 }
767 }
768 f, err := loadCredentials()
769 if err != nil {
770 return err
771 }
772 f.put(credential{Console: base, Address: poll.Address, Token: poll.Token,
773 Label: *label, CreatedAt: now, ExpiresAt: expires})
774 if err := f.save(); err != nil {
775 return fmt.Errorf("storing the credential: %w", err)
776 }
777 // Checked here as well as in whoami: this is the moment a bearer
778 // token first exists on this machine, and a pre-existing file with
779 // loose permissions is exactly when to say so.
780 if warn := credentialPermWarning(); warn != "" {
781 warnf(out, "%s", warn)
782 }
783 fmt.Fprintf(out, "\n%s to %s as %s.\n", pal.ok("Logged in"), base, poll.Address)
784 mutedf(out, "Token stored in %s (mode 0600); it expires %s.",
785 credentialsPath(), expires.Format("2006-01-02"))
786 mutedf(out, "Revoke it any time at %s/auth/cli/tokens", base)
787 return nil
788 default:
789 return fmt.Errorf("the console answered with an unknown status %q", poll.Status)
790 }
791 }
792 return errors.New("gave up waiting for approval — run this again when you're at a browser")
793}
794
795func authWhoami(out io.Writer, args []string) error {
796 fs := flag.NewFlagSet("auth whoami", flag.ContinueOnError)
797 fs.SetOutput(out)
798 console := consoleOption(fs, "which console (only needed with several)")
799 fs.Usage = flagUsage(fs, "")
800 if err := fs.Parse(args); err != nil {
801 return err
802 }
803 f, err := loadCredentials()
804 if err != nil {
805 return err
806 }
807 c, err := f.pick(*console)
808 if err != nil {
809 return err
810 }
811 if warn := credentialPermWarning(); warn != "" {
812 warnf(out, "%s", warn)
813 }
814 var who whoamiResponse
815 if _, err := newConsoleClient(c.Console, c.Token).
816 do(context.Background(), http.MethodGet, "/auth/cli/whoami", nil, &who); err != nil {
817 // Deliberately shows the STORED identity as well: "the console says
818 // no" and "I could not reach the console" are different problems, and
819 // a person debugging one needs to know which they have.
820 return fmt.Errorf("%s says: %v (this machine holds a credential for %s)", c.Console, err, c.Address)
821 }
822 fmt.Fprintf(out, "%s at %s\n", who.Address, c.Console)
823 if who.Label != "" {
824 mutedf(out, " terminal: %s", who.Label)
825 }
826 if who.ExpiresAt != "" {
827 mutedf(out, " expires: %s", who.ExpiresAt)
828 }
829 // Memberships are a bonus line, not part of whoami's contract: identity
830 // already printed, and a console from before GET /api/cli/apps (or a
831 // blip fetching it) must not make whoami look broken — so ANY error
832 // here degrades to silence rather than a message, and the moved-console
833 // OFFER is suppressed too: a bonus line must never turn into a prompt.
834 quietConsoleOffers = true
835 defer func() { quietConsoleOffers = false }()
836 var acc struct {
837 Accounts []apiAccountEntry `json:"accounts"`
838 }
839 if _, err := newConsoleClient(c.Console, c.Token).
840 do(context.Background(), http.MethodGet, "/api/cli/apps", nil, &acc); err == nil && len(acc.Accounts) > 0 {
841 rows := make([]string, 0, len(acc.Accounts))
842 for _, a := range acc.Accounts {
843 rows = append(rows, fmt.Sprintf("%s (%s)", a.Name, a.Sqid))
844 }
845 mutedf(out, " accounts: %s", strings.Join(rows, ", "))
846 }
847 return nil
848}
849
850func authLogout(out io.Writer, args []string) error {
851 fs := flag.NewFlagSet("auth logout", flag.ContinueOnError)
852 fs.SetOutput(out)
853 console := consoleOption(fs, "which console (only needed with several)")
854 fs.Usage = flagUsage(fs, "")
855 if err := fs.Parse(args); err != nil {
856 return err
857 }
858 f, err := loadCredentials()
859 if err != nil {
860 return err
861 }
862 c, err := f.pick(*console)
863 if err != nil {
864 return err
865 }
866 origin := c.Console
867 // Revoke first, then forget. If the console cannot be reached the local
868 // copy still goes — but say so, because a token this machine has stopped
869 // tracking is still a live credential until it expires, and the person
870 // needs to know to kill it from the console.
871 revokeErr := func() error {
872 _, err := newConsoleClient(origin, c.Token).
873 do(context.Background(), http.MethodPost, "/auth/cli/revoke", map[string]string{}, nil)
874 return err
875 }()
876 f.remove(origin)
877 if err := f.save(); err != nil {
878 return err
879 }
880 if revokeErr != nil {
881 warnf(out, "Forgot the credential for %s locally, but could not revoke it there: %v", origin, revokeErr)
882 warnf(out, "It stays live until it expires — revoke it at %s/auth/cli/tokens", origin)
883 return nil
884 }
885 fmt.Fprintf(out, "%s of %s — the token is revoked.\n", colorFor(out).ok("Logged out"), origin)
886 return nil
887}
888
889// authDefault shows or sets the default console. Bare: report the effective
890// configured default and which file said so (flag and env are not consulted
891// — this verb reports CONFIG, and someone with CARLOS_CONSOLE exported can
892// see their own shell). With a URL: write ~/.carlos/config, or
893// ./.carlos/config under --project — relative to the working directory,
894// deliberately not "the repo root": the CLI has no VCS awareness, and
895// "run it where you want the file" beats guessing.
896func authDefault(out io.Writer, args []string) error {
897 fs := flag.NewFlagSet("auth default", flag.ContinueOnError)
898 fs.SetOutput(out)
899 project := fs.Bool("project", false, "write ./.carlos/config (this directory) instead of ~/.carlos/config")
900 destinationOption(fs) // reads, and writes into, that destination's block
901 fs.Usage = flagUsage(fs, "")
902 if err := fs.Parse(args); err != nil {
903 return err
904 }
905 switch fs.NArg() {
906 case 0:
907 if *project {
908 return errors.New("--project sets a default: carlos auth default --project <url>")
909 }
910 console, src, err := configDefault()
911 if err != nil {
912 return err
913 }
914 if console == "" {
915 fmt.Fprintf(out, "no default console configured.\n")
916 fmt.Fprintf(out, "`carlos auth login` defaults to %s; other commands use the\n", defaultConsole)
917 fmt.Fprintf(out, "only stored credential, or ask when there are several.\n")
918 fmt.Fprintf(out, "Set one with: carlos auth default <url> (--project pins this directory)\n")
919 return nil
920 }
921 if d := configDestination(console); d != "" {
922 src = "the " + d + " destination in " + src
923 }
924 fmt.Fprintf(out, "%s (from %s)\n", console, src)
925 return nil
926 case 1:
927 path, err := globalConfigPath()
928 perm := os.FileMode(0o700) // ~/.carlos also holds credentials
929 if *project {
930 path, err = filepath.Join(".carlos", "config"), nil
931 perm = 0o755 // meant to be committed; nothing secret inside
932 }
933 if err != nil {
934 return err
935 }
936 if err := saveConfigFile(path, fs.Arg(0), perm); err != nil {
937 return err
938 }
939 // Echo the NORMALIZED value: what was saved, not what was typed.
940 console, err := normalizeConsole(fs.Arg(0))
941 if err != nil {
942 return err
943 }
944 if d := selectedDestination(); d != "" {
945 path = "the " + d + " destination in " + path
946 }
947 fmt.Fprintf(out, "default console: %s (written to %s)\n", console, path)
948 return nil
949 default:
950 return errors.New("usage: carlos auth default [--project] [-d <destination>] [<url>]")
951 }
952}
953
954// ---- what other verbs use ---------------------------------------------------
955
956// loggedInCredential is the seam every other verb reaches through: the
957// credential for a console, or nil with the reason. Never fatal — the callers
958// have their own answer for "not logged in" (usually: name --owner).
959func loggedInCredential(console string) (*credential, error) {
960 f, err := loadCredentials()
961 if err != nil {
962 return nil, err
963 }
964 return f.pick(console)
965}
966