| new file mode 100644 |
| index 000000000..20afff345 |
| --- /dev/null |
| +++ b/auth.go |
| @@ -0,0 +1,617 @@ |
| +// 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" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "os/user" |
| + "path/filepath" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const authUsage = `usage: carlos auth <login|whoami|logout> |
| + |
| + login -console <url> log this terminal in to a deployment's console. |
| + 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 <name>] what to call this terminal on the console's |
| + command-line access page (default: user@host) |
| + whoami [-console <url>] ask the console who this terminal is |
| + logout [-console <url>] revoke this terminal's token and forget it |
| + |
| +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. |
| + |
| +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 <address> 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:]) |
| + default: |
| + fmt.Fprint(os.Stderr, authUsage) |
| + os.Exit(1) |
| + } |
| + if err != nil { |
| + fmt.Fprintf(os.Stderr, "carlos auth %s: %v\n", 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 |
| +// wins; otherwise the only credential is used, and several 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. |
| +func (f *credentialFile) pick(console string) (*credential, error) { |
| + if console == "" { |
| + console = os.Getenv("CARLOS_CONSOLE") |
| + } |
| + if console != "" { |
| + want, err := normalizeConsole(console) |
| + if err != nil { |
| + return nil, err |
| + } |
| + c := f.find(want) |
| + if c == nil { |
| + return nil, fmt.Errorf("not logged in to %s — run: carlos auth login -console %s", want, want) |
| + } |
| + if c.Expired(time.Now()) { |
| + return nil, fmt.Errorf("the credential for %s expired %s — run: carlos auth login -console %s", |
| + want, 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: |
| + return nil, errors.New("not logged in — run: carlos auth login -console <url>") |
| + 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: %s", |
| + strings.Join(names, ", ")) |
| + } |
| +} |
| + |
| +// 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) |
| + } |
| + return u.Scheme + "://" + u.Host, nil |
| +} |
| + |
| +// ---- 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 { |
| + return &consoleClient{base: base, token: token, httpc: &http.Client{Timeout: 30 * time.Second}} |
| +} |
| + |
| +// 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() |
| + 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 |
| +} |
| + |
| +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 |
| +} |
| + |
| +// pollWait bounds what a console can ask the CLI to sleep for. A console we |
| +// are about to trust with our identity is not one we need to defend against |
| +// hard, but an unbounded sleep from a JSON field is still not something to |
| +// hand a program. |
| +func pollWait(seconds int) time.Duration { |
| + if seconds < 0 { |
| + seconds = 0 |
| + } |
| + if seconds > 30 { |
| + seconds = 30 |
| + } |
| + return time.Duration(seconds) * time.Second |
| +} |
| + |
| +func authLogin(out io.Writer, args []string) error { |
| + fs := flag.NewFlagSet("auth login", flag.ContinueOnError) |
| + fs.SetOutput(out) |
| + console := fs.String("console", os.Getenv("CARLOS_CONSOLE"), "the console to log in to (https://…)") |
| + label := fs.String("label", "", "what to call this terminal on the console (default: user@host)") |
| + if err := fs.Parse(args); err != nil { |
| + return err |
| + } |
| + if *console == "" { |
| + return errors.New("which console? pass -console https://platform.carlosframework.com " + |
| + "(or set CARLOS_CONSOLE). On a box with no browser, don't log in at all — " + |
| + "pass -owner <address> to `carlos accounts create` instead") |
| + } |
| + base, err := normalizeConsole(*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) |
| + } |
| + link := start.VerifyURIFull |
| + if link == "" { |
| + link = base + "/auth/cli?code=" + url.QueryEscape(start.UserCode) |
| + } |
| + // Printed, never opened: this frequently runs over SSH, where opening a |
| + // browser would open it on the wrong machine. |
| + fmt.Fprintf(out, "\nOpen this in a browser signed in to %s:\n\n %s\n\n", base, link) |
| + fmt.Fprintf(out, "and check it shows the code %s\n\n", start.UserCode) |
| + fmt.Fprintf(out, "Waiting for approval (this code expires in %d minutes)…\n", start.ExpiresIn/60) |
| + |
| + deadline := time.Now().Add(time.Duration(start.ExpiresIn) * time.Second) |
| + if start.ExpiresIn <= 0 { |
| + deadline = time.Now().Add(10 * time.Minute) |
| + } |
| + 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) |
| + } |
| + fmt.Fprintf(out, "\nLogged in to %s as %s.\n", base, poll.Address) |
| + fmt.Fprintf(out, "Token stored in %s (mode 0600); it expires %s.\n", |
| + credentialsPath(), expires.Format("2006-01-02")) |
| + fmt.Fprintf(out, "Revoke it any time at %s/auth/cli/tokens\n", 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 := fs.String("console", "", "which console (only needed with several)") |
| + 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 != "" { |
| + fmt.Fprintln(out, 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 != "" { |
| + fmt.Fprintf(out, " terminal: %s\n", who.Label) |
| + } |
| + if who.ExpiresAt != "" { |
| + fmt.Fprintf(out, " expires: %s\n", who.ExpiresAt) |
| + } |
| + return nil |
| +} |
| + |
| +func authLogout(out io.Writer, args []string) error { |
| + fs := flag.NewFlagSet("auth logout", flag.ContinueOnError) |
| + fs.SetOutput(out) |
| + console := fs.String("console", "", "which console (only needed with several)") |
| + 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 { |
| + fmt.Fprintf(out, "Forgot the credential for %s locally, but could not revoke it there: %v\n", origin, revokeErr) |
| + fmt.Fprintf(out, "It stays live until it expires — revoke it at %s/auth/cli/tokens\n", origin) |
| + return nil |
| + } |
| + fmt.Fprintf(out, "Logged out of %s — the token is revoked.\n", origin) |
| + return nil |
| +} |
| + |
| +// ---- 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) |
| +} |