carlos / platform Public

console: the CLI login endpoints, the approval page, and a kill switch

The console is already this deployment's identity authority and already a
registered Keymail OAuth client, so the CLI authenticates against it
rather than minting a second client: one consent screen, one place to
revoke, and a self-hoster's CLI trusts their own box instead of a third
party.

The unauthenticated half (start, poll) is rate-limited per IP and per
device code and insists on a JSON content type — which is the CSRF story
there, since a cross-site form cannot send one. The half a human drives
(approve, revoke) is session-gated with the same double-submitted session
token every other mutation here uses, and wrong codes are counted so the
one guessable secret in the flow is not guessable in its ten minutes.

`carlos accounts create` from a logged-in CLI mints THROUGH the console
(handleCLINewAccount), because the console's own mintAccount writes the
bucket manifest and the owner membership in one path — which is exactly
what the bucket-only mint could not do, and why accounts turned up that
their creator could not open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell 6043f9c75564e74e304d12a6413911e54d71c232 parent 00221bc
5 files changed, +916 −1
  • internal/console/authcli.go +494 −0
  • internal/console/authcli_test.go +334 −0
  • internal/console/server.go +6 −1
  • internal/console/templates/cli_approve.html +47 −0
  • internal/console/templates/cli_tokens.html +35 −0
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
new file mode 100644
index 000000000..b92c54653
--- /dev/null
+++ b/internal/console/authcli.go
@@ -0,0 +1,494 @@
+// CLI identity, HTTP half: the endpoints `carlos auth login` speaks to, and
+// the two pages a human uses — approve a terminal, and see (or kill) every
+// terminal that speaks as them.
+//
+// WHY THE CONSOLE AND NOT A SECOND KEYMAIL CLIENT. The console is already a
+// registered Keymail OAuth client and already the deployment's identity
+// authority: it knows who you are, which accounts you belong to, and what
+// role you hold in each. Minting a SECOND OAuth client for the CLI would mean
+// a second consent screen, a second grant to revoke, a second client_id to
+// register per self-hosted deployment — and a CLI that trusts keymail.dev
+// directly rather than trusting the console the operator actually runs. So
+// the CLI authenticates against the deployment's own console, and the console
+// authenticates the human with the Keymail sign-in it already has. One OAuth
+// client, one place to revoke, and a self-hoster's CLI trusts their own box.
+//
+// WHY A TYPED CODE AND NOT A LOCALHOST CALLBACK. The terminal that needs a
+// credential is very often an SSH session on a box, a container, or a CI
+// shell. A browser-redirect flow assumes the browser can reach a listener on
+// the machine running the CLI, which in all three cases it cannot. The typed
+// code crosses that gap by hand: it is short enough to retype from a phone,
+// and the credential is delivered back over the CLI's own polling channel.
+package console
+
+import (
+ "context"
+ "crypto/subtle"
+ "encoding/json"
+ "log"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Rate limits. Deliberately generous for humans and tight for scripts: the
+// numbers only have to make brute force and database-stuffing pointless, not
+// police normal use.
+const (
+ cliRateWindow = time.Minute
+ // cliStartBurst bounds unauthenticated login starts per client IP. Each
+ // start writes a row, so this is the anti-stuffing limit.
+ cliStartBurst = 10
+ // cliPollBurst is per device code — a polite CLI polls every 2s, so 60 a
+ // minute is twice what the honest client needs and nowhere near a spin.
+ cliPollBurst = 60
+ // cliGuessBurst bounds WRONG typed codes per session. The user code is
+ // ~39 bits with a ten-minute life; at 10 guesses a minute it is not
+ // findable in that window by any margin that matters.
+ cliGuessBurst = 10
+ // cliWriteBurst bounds authenticated mutations per address.
+ cliWriteBurst = 30
+ // cliPollInterval is the wait the console asks the CLI to keep, seconds.
+ cliPollInterval = 2
+)
+
+// rateLimiter is a fixed-window counter, keyed by whatever the caller thinks
+// identifies a client. In-memory and per-process on purpose: the console is
+// one process on one box, and reaching for a shared store here would add a
+// dependency to buy nothing.
+type rateLimiter struct {
+ mu sync.Mutex
+ max int
+ window time.Duration
+ hits map[string]*rateWindow
+}
+
+type rateWindow struct {
+ start time.Time
+ n int
+}
+
+func newRateLimiter(max int, window time.Duration) *rateLimiter {
+ return &rateLimiter{max: max, window: window, hits: map[string]*rateWindow{}}
+}
+
+// allow records one hit and reports whether it is within the window's budget.
+func (l *rateLimiter) allow(key string, now time.Time) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ // Cheap sweep so a long-running console does not accumulate a key per IP
+ // that ever touched it. Bounded work: only when the map has grown.
+ if len(l.hits) > 4096 {
+ for k, w := range l.hits {
+ if now.Sub(w.start) > l.window {
+ delete(l.hits, k)
+ }
+ }
+ }
+ w := l.hits[key]
+ if w == nil || now.Sub(w.start) > l.window {
+ l.hits[key] = &rateWindow{start: now, n: 1}
+ return true
+ }
+ w.n++
+ return w.n <= l.max
+}
+
+// cliLimits is the set of limiters the CLI endpoints share, hung off the
+// Server so a test gets a fresh set per fixture.
+type cliLimits struct {
+ start, poll, guess, write *rateLimiter
+}
+
+func newCLILimits() *cliLimits {
+ return &cliLimits{
+ start: newRateLimiter(cliStartBurst, cliRateWindow),
+ poll: newRateLimiter(cliPollBurst, cliRateWindow),
+ guess: newRateLimiter(cliGuessBurst, cliRateWindow),
+ write: newRateLimiter(cliWriteBurst, cliRateWindow),
+ }
+}
+
+// registerCLIRoutes wires the CLI-identity surface. Called from NewServer.
+func (s *Server) registerCLIRoutes() {
+ s.mux.HandleFunc("POST /auth/cli/start", s.handleCLIStart)
+ s.mux.HandleFunc("POST /auth/cli/poll", s.handleCLIPoll)
+ s.mux.HandleFunc("GET /auth/cli", s.handleCLIApprovePage)
+ s.mux.HandleFunc("POST /auth/cli/approve", s.handleCLIApprove)
+ s.mux.HandleFunc("GET /auth/cli/tokens", s.handleCLITokens)
+ s.mux.HandleFunc("POST /auth/cli/tokens/revoke", s.handleCLITokenRevoke)
+ s.mux.HandleFunc("GET /auth/cli/whoami", s.handleCLIWhoami)
+ s.mux.HandleFunc("POST /auth/cli/revoke", s.handleCLIRevoke)
+ s.mux.HandleFunc("POST /auth/cli/accounts", s.handleCLINewAccount)
+}
+
+// clientIP is who the rate limiter counts against. The console runs behind
+// the edge, which sets X-Forwarded-For with SetXForwarded — appending the
+// real client to whatever arrived — so the LAST element is the edge's own
+// view of the peer and the only element not under the client's control.
+func clientIP(r *http.Request) string {
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+ parts := strings.Split(xff, ",")
+ if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" {
+ return ip
+ }
+ }
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
+
+// ---- JSON plumbing ----------------------------------------------------------
+
+func writeJSON(w http.ResponseWriter, status int, body any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(status)
+ if err := json.NewEncoder(w).Encode(body); err != nil {
+ log.Printf("console: write json: %v", err)
+ }
+}
+
+func jsonError(w http.ResponseWriter, status int, msg string) {
+ writeJSON(w, status, map[string]string{"error": msg})
+}
+
+// readJSON decodes a CLI request body, insisting on a JSON content type.
+//
+// That insistence IS the CSRF defence for the unauthenticated endpoints: a
+// cross-site HTML form can only send urlencoded, multipart or plain text, so
+// a body this accepts cannot have been sent by a page the user merely
+// visited. Anything that can set application/json cross-origin has to pass a
+// CORS preflight first, and this console answers none.
+func readJSON(w http.ResponseWriter, r *http.Request, out any) bool {
+ ct, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";")
+ if strings.TrimSpace(strings.ToLower(ct)) != "application/json" {
+ jsonError(w, http.StatusUnsupportedMediaType, "send a JSON body with Content-Type: application/json")
+ return false
+ }
+ // A login request is a handful of short strings; anything larger is not
+ // one of ours.
+ r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
+ if err := json.NewDecoder(r.Body).Decode(out); err != nil {
+ jsonError(w, http.StatusBadRequest, "malformed JSON body")
+ return false
+ }
+ return true
+}
+
+// bearer pulls the token out of the Authorization header.
+func bearer(r *http.Request) string {
+ h := r.Header.Get("Authorization")
+ if len(h) < 7 || !strings.EqualFold(h[:7], "bearer ") {
+ return ""
+ }
+ return strings.TrimSpace(h[7:])
+}
+
+// cliUser authenticates a CLI request. Every failure is the same 401 with the
+// same body: an expired token, a revoked one and a fabricated one are not
+// distinguishable from outside.
+func (s *Server) cliUser(w http.ResponseWriter, r *http.Request) (string, bool) {
+ addr, ok := s.store.CLITokenAddress(bearer(r), time.Now())
+ if !ok {
+ jsonError(w, http.StatusUnauthorized, "not signed in — run: carlos auth login -console "+s.cfg.Origin)
+ return "", false
+ }
+ return addr, true
+}
+
+// ---- the flow ---------------------------------------------------------------
+
+// cleanLabel bounds and de-fangs what a terminal calls itself. It is rendered
+// on the approval page (auto-escaped there) and stored; control characters
+// would let a caller scribble on an operator's terminal when it is echoed
+// back by the CLI.
+func cleanLabel(in string) string {
+ var b strings.Builder
+ for _, r := range in {
+ if r < 0x20 || r == 0x7f {
+ continue
+ }
+ b.WriteRune(r)
+ if b.Len() >= 64 {
+ break
+ }
+ }
+ return strings.TrimSpace(b.String())
+}
+
+// handleCLIStart opens a login attempt. Unauthenticated by necessity — the
+// terminal has no credential yet — which is why it is rate-limited per IP,
+// mints nothing but a ten-minute row, and grants nothing until a signed-in
+// human approves it.
+func (s *Server) handleCLIStart(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Label string `json:"label"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ ip := clientIP(r)
+ if !s.cli.start.allow(ip, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many login attempts from here — wait a minute and try again")
+ return
+ }
+ userCode, deviceCode, err := s.store.NewCLIAuth(cleanLabel(req.Label), ip, time.Now())
+ if err != nil {
+ log.Printf("console: cli auth start: %v", err)
+ jsonError(w, http.StatusInternalServerError, "could not start the login — try again")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "user_code": userCode,
+ "device_code": deviceCode,
+ "verification_uri": s.cfg.Origin + "/auth/cli",
+ "verification_uri_complete": s.cfg.Origin + "/auth/cli?code=" + url.QueryEscape(userCode),
+ "interval": cliPollInterval,
+ "expires_in": int(CLIAuthTTL.Seconds()),
+ })
+}
+
+// handleCLIPoll is the CLI's waiting room. Every answer is 200 with a status
+// word — the CLI is a program, and a status it can switch on beats an HTTP
+// code it has to interpret.
+func (s *Server) handleCLIPoll(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ DeviceCode string `json:"device_code"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ // Keyed on the device code, not the IP: two terminals behind one NAT must
+ // not throttle each other, and a device code is exactly one flow.
+ if !s.cli.poll.allow(hashToken(req.DeviceCode), time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "polling too fast")
+ return
+ }
+ status, token, err := s.store.PollCLIAuth(req.DeviceCode, time.Now())
+ if err != nil {
+ log.Printf("console: cli auth poll: %v", err)
+ jsonError(w, http.StatusInternalServerError, "could not check the login — try again")
+ return
+ }
+ out := map[string]any{"status": status, "interval": cliPollInterval}
+ if status == CLIAuthApproved {
+ addr, _ := s.store.CLITokenAddress(token, time.Now())
+ out["token"] = token
+ out["address"] = addr
+ out["expires_at"] = time.Now().Add(CLITokenTTL).UTC().Format(time.RFC3339)
+ out["console"] = s.cfg.Origin
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+// cliPage is the approval and tokens pages' data. It embeds page so the
+// shared chrome ("top"/"bottom") keeps working unchanged.
+type cliPage struct {
+ page
+ Code string
+ Console string // this deployment's origin — the -console the CLI needs
+ Request *CLIAuthRequest
+ Tokens []CLIToken
+ Days int
+}
+
+func (s *Server) renderCLI(w http.ResponseWriter, status int, name string, p cliPage) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if err := templates.ExecuteTemplate(w, name, p); err != nil {
+ log.Printf("console: render %s: %v", name, err)
+ }
+}
+
+// handleCLIApprovePage is where the printed link lands. A signed-out visitor
+// goes to the front page to sign in and re-opens the link — the code is in
+// their terminal, so nothing is lost, and the console never carries a pending
+// approval through an authentication it has not finished.
+func (s *Server) handleCLIApprovePage(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ p := cliPage{page: page{Title: "Approve a terminal", Address: addr, CSRF: raw},
+ Days: int(CLITokenTTL / (24 * time.Hour))}
+ p.Code = NormalizeUserCode(r.URL.Query().Get("code"))
+ if p.Code != "" {
+ req, err := s.store.CLIAuthByUserCode(p.Code, time.Now())
+ if err != nil {
+ p.Error = "That code has expired or was already used. Run `carlos auth login` again for a fresh one."
+ } else {
+ p.Request = req
+ }
+ }
+ s.renderCLI(w, http.StatusOK, "cli_approve", p)
+}
+
+// handleCLIApprove is the decision. Session-gated and CSRF-checked with the
+// double-submitted session token, exactly like every other mutation here: a
+// cross-site POST that could approve a terminal would be the whole attack.
+func (s *Server) handleCLIApprove(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ code := NormalizeUserCode(r.FormValue("code"))
+ deny := r.FormValue("action") == "deny"
+ p := cliPage{page: page{Title: "Approve a terminal", Address: addr, CSRF: raw},
+ Code: code, Days: int(CLITokenTTL / (24 * time.Hour))}
+ now := time.Now()
+ var err error
+ if deny {
+ err = s.store.DenyCLIAuth(code, now)
+ } else {
+ err = s.store.ApproveCLIAuth(code, addr, now)
+ }
+ if err != nil {
+ // Only WRONG codes are counted: a human who mistypes twice and then
+ // gets it right is not an attacker, but a script working through the
+ // code space only ever lands here.
+ if !s.cli.guess.allow(hashToken(raw), now) {
+ s.renderMessage(w, r, http.StatusTooManyRequests, "Too many attempts",
+ "Too many bad login codes. Wait a minute, then try again.")
+ return
+ }
+ p.Error = "That code has expired or was already used. Run `carlos auth login` again for a fresh one."
+ s.renderCLI(w, http.StatusOK, "cli_approve", p)
+ return
+ }
+ p.Title = "Approved"
+ if deny {
+ p.Title = "Denied"
+ }
+ s.renderCLI(w, http.StatusOK, "cli_decided", p)
+}
+
+// handleCLITokens lists the signed-in user's own CLI credentials. Per USER,
+// not per account: a token is an identity, and it carries whatever accounts
+// that identity belongs to — so the person who can kill it is the person it
+// speaks as.
+func (s *Server) handleCLITokens(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ toks, err := s.store.CLITokens(addr)
+ if err != nil {
+ log.Printf("console: cli tokens: %v", err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your terminals. Please try again.")
+ return
+ }
+ p := cliPage{page: page{Title: "Command-line access", Address: addr, CSRF: raw},
+ Tokens: toks, Console: s.cfg.Origin, Days: int(CLITokenTTL / (24 * time.Hour))}
+ p.Flash, p.FlashErr = s.takeFlash(w, r)
+ s.renderCLI(w, http.StatusOK, "cli_tokens", p)
+}
+
+func (s *Server) handleCLITokenRevoke(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ // Scoped to the signed-in address inside the store call: an id belonging
+ // to somebody else simply is not found.
+ if err := s.store.RevokeCLIToken(addr, r.FormValue("id")); err != nil {
+ s.setFlash(w, "err", "That terminal is already gone.")
+ } else {
+ s.setFlash(w, "ok", "Revoked — that terminal can no longer act as you.")
+ }
+ http.Redirect(w, r, "/auth/cli/tokens", http.StatusSeeOther)
+}
+
+// handleCLIWhoami answers `carlos auth whoami`: who this token speaks as, and
+// until when. It is also the liveness check every verb that acts on the
+// token's identity makes before acting.
+func (s *Server) handleCLIWhoami(w http.ResponseWriter, r *http.Request) {
+ addr, ok := s.cliUser(w, r)
+ if !ok {
+ return
+ }
+ out := map[string]any{"address": addr, "console": s.cfg.Origin}
+ if toks, err := s.store.CLITokens(addr); err == nil {
+ hash := hashToken(bearer(r))
+ for _, t := range toks {
+ if t.ID == hash {
+ out["label"] = t.Label
+ out["expires_at"] = t.ExpiresAt.Format(time.RFC3339)
+ }
+ }
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+// handleCLIRevoke is `carlos auth logout`: retire this credential rather than
+// merely forgetting it locally.
+func (s *Server) handleCLIRevoke(w http.ResponseWriter, r *http.Request) {
+ if _, ok := s.cliUser(w, r); !ok {
+ return
+ }
+ if err := s.store.RevokeCLITokenValue(bearer(r)); err != nil {
+ log.Printf("console: cli revoke: %v", err)
+ jsonError(w, http.StatusInternalServerError, "could not revoke the token")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"revoked": true})
+}
+
+// handleCLINewAccount is the point of the whole feature: `carlos accounts
+// create` from a logged-in CLI mints the account THROUGH the console, so the
+// human who ran it is its owner from the first instant.
+//
+// The alternative — the CLI minting straight into the bucket and hoping
+// something later notices who owns it — is exactly what produced accounts
+// nobody could open in the console. The console already has mintAccount: the
+// bucket manifest and the owner membership in one path.
+func (s *Server) handleCLINewAccount(w http.ResponseWriter, r *http.Request) {
+ addr, ok := s.cliUser(w, r)
+ if !ok {
+ return
+ }
+ if !s.cli.write.allow(addr, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ var req struct {
+ Name string `json:"name"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ name := strings.TrimSpace(req.Name)
+ if name == "" || len(name) > 64 {
+ jsonError(w, http.StatusBadRequest, "an account needs a name (up to 64 characters)")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+ a, err := s.mintAccount(ctx, name, addr)
+ if err != nil {
+ log.Printf("console: cli mint account for %s: %v", addr, err)
+ jsonError(w, http.StatusBadGateway, "could not create the account: "+err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "id": a.ID, "sqid": a.Sqid, "name": a.Name, "owner": addr,
+ "url": s.cfg.Origin + "/a/" + url.PathEscape(a.Sqid) + "/",
+ })
+}
diff --git a/internal/console/authcli_test.go b/internal/console/authcli_test.go
new file mode 100644
index 000000000..082a4b468
--- /dev/null
+++ b/internal/console/authcli_test.go
@@ -0,0 +1,334 @@
+package console
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// postJSON is how the CLI talks to the console: a JSON body, an optional
+// bearer token, and no cookies.
+func postJSON(t *testing.T, u string, body any, bearer string) (int, map[string]any) {
+ t.Helper()
+ raw, err := json.Marshal(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ return doJSON(t, req)
+}
+
+func getJSON(t *testing.T, u, bearer string) (int, map[string]any) {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodGet, u, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ return doJSON(t, req)
+}
+
+func doJSON(t *testing.T, req *http.Request) (int, map[string]any) {
+ t.Helper()
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ raw, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := map[string]any{}
+ if len(bytes.TrimSpace(raw)) > 0 {
+ if err := json.Unmarshal(raw, &out); err != nil {
+ t.Fatalf("%s %s: body is not JSON: %v\n%s", req.Method, req.URL, err, raw)
+ }
+ }
+ return res.StatusCode, out
+}
+
+func str(t *testing.T, m map[string]any, k string) string {
+ t.Helper()
+ v, _ := m[k].(string)
+ return v
+}
+
+// TestCLILoginEndToEnd is the whole ceremony against the real handlers: a
+// terminal starts a login, a signed-in human approves it in a browser, the
+// terminal collects a token, uses it, sees it listed, and revokes it.
+func TestCLILoginEndToEnd(t *testing.T) {
+ fx := newConsoleFixture(t)
+ browser := newBrowser(t)
+ signUp(t, fx, browser, "code-cli")
+
+ status, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "paul@laptop"}, "")
+ if status != http.StatusOK {
+ t.Fatalf("start = %d %v", status, start)
+ }
+ userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code")
+ if userCode == "" || deviceCode == "" {
+ t.Fatalf("start returned %v, want both codes", start)
+ }
+ if got, want := str(t, start, "verification_uri"), fx.ts.URL+"/auth/cli"; got != want {
+ t.Fatalf("verification_uri = %q, want %q", got, want)
+ }
+ if got := str(t, start, "verification_uri_complete"); !strings.Contains(got, url.QueryEscape(userCode)) {
+ t.Fatalf("verification_uri_complete = %q, want the code in it", got)
+ }
+
+ // Nothing yet.
+ if _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, ""); str(t, poll, "status") != CLIAuthPending {
+ t.Fatalf("poll before approval = %v, want pending", poll)
+ }
+
+ // The approval page names the terminal, so a human can tell theirs from
+ // somebody else's.
+ code, _, body := get(t, browser, fx.ts.URL+"/auth/cli?code="+url.QueryEscape(userCode))
+ if code != http.StatusOK {
+ t.Fatalf("approval page = %d", code)
+ }
+ for _, want := range []string{"paul@laptop", userCode, "Approve"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("approval page missing %q in:\n%s", want, body)
+ }
+ }
+
+ // CSRF: the session token double-submitted, exactly like every other
+ // mutation on this console.
+ csrf := sessionToken(t, fx, browser)
+ if code, _, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/approve",
+ url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {"wrong"}}); code != http.StatusForbidden {
+ t.Fatalf("approve with a bad CSRF token = %d, want 403", code)
+ }
+ // And a signed-out browser cannot approve at all.
+ if code, loc, _ := postForm(t, newBrowser(t), fx.ts.URL+"/auth/cli/approve",
+ url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {csrf}}); code != http.StatusFound || loc != "/" {
+ t.Fatalf("signed-out approve = %d -> %q, want 302 -> /", code, loc)
+ }
+
+ code, _, body = postForm(t, browser, fx.ts.URL+"/auth/cli/approve",
+ url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {csrf}})
+ if code != http.StatusOK || !strings.Contains(body, "Approved") {
+ t.Fatalf("approve = %d, body:\n%s", code, body)
+ }
+
+ _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, "")
+ if str(t, poll, "status") != CLIAuthApproved {
+ t.Fatalf("poll after approval = %v", poll)
+ }
+ token := str(t, poll, "token")
+ if token == "" || str(t, poll, "address") != "paul@keymail.dev" {
+ t.Fatalf("poll after approval = %v, want a token bound to paul", poll)
+ }
+
+ // The token identifies its holder — and only with the right bearer.
+ status, who := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token)
+ if status != http.StatusOK || str(t, who, "address") != "paul@keymail.dev" {
+ t.Fatalf("whoami = %d %v", status, who)
+ }
+ if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token+"x"); status != http.StatusUnauthorized {
+ t.Fatalf("whoami with a wrong token = %d, want 401", status)
+ }
+ if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", ""); status != http.StatusUnauthorized {
+ t.Fatalf("whoami with no token = %d, want 401", status)
+ }
+
+ // The owner can see it, and kill it, from the console.
+ code, _, body = get(t, browser, fx.ts.URL+"/auth/cli/tokens")
+ if code != http.StatusOK || !strings.Contains(body, "paul@laptop") {
+ t.Fatalf("tokens page = %d, body:\n%s", code, body)
+ }
+ if strings.Contains(body, token) {
+ t.Fatal("the tokens page prints the bearer token itself")
+ }
+ id := ""
+ toks, err := fx.st.CLITokens("paul@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(toks) != 1 {
+ t.Fatalf("CLITokens = %d, want 1", len(toks))
+ }
+ id = toks[0].ID
+ if code, _, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/tokens/revoke",
+ url.Values{"id": {id}, "csrf": {"wrong"}}); code != http.StatusForbidden {
+ t.Fatalf("revoke with a bad CSRF token = %d, want 403", code)
+ }
+ if code, _, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/tokens/revoke",
+ url.Values{"id": {id}, "csrf": {csrf}}); code != http.StatusSeeOther {
+ t.Fatalf("revoke = %d, want 303", code)
+ }
+ if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token); status != http.StatusUnauthorized {
+ t.Fatalf("whoami after revocation = %d, want 401", status)
+ }
+}
+
+// A denied login is final and tells the terminal so.
+func TestCLILoginDeny(t *testing.T) {
+ fx := newConsoleFixture(t)
+ browser := newBrowser(t)
+ signUp(t, fx, browser, "code-deny")
+ _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "someone@elsewhere"}, "")
+ userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code")
+ code, _, body := postForm(t, browser, fx.ts.URL+"/auth/cli/approve",
+ url.Values{"code": {userCode}, "action": {"deny"}, "csrf": {sessionToken(t, fx, browser)}})
+ if code != http.StatusOK || !strings.Contains(body, "Denied") {
+ t.Fatalf("deny = %d, body:\n%s", code, body)
+ }
+ _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, "")
+ if str(t, poll, "status") != CLIAuthDenied {
+ t.Fatalf("poll after deny = %v, want denied", poll)
+ }
+}
+
+// The JSON endpoints refuse a form-encoded body. That is the CSRF story for
+// the unauthenticated half of the flow: a cross-site <form> can only send
+// form encodings, and anything that can set application/json has already been
+// through a CORS preflight this console never answers.
+func TestCLIStartRefusesFormEncoding(t *testing.T) {
+ fx := newConsoleFixture(t)
+ res, err := http.PostForm(fx.ts.URL+"/auth/cli/start", url.Values{"label": {"x"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ res.Body.Close()
+ if res.StatusCode != http.StatusUnsupportedMediaType {
+ t.Fatalf("form-encoded start = %d, want 415", res.StatusCode)
+ }
+}
+
+// An unauthenticated endpoint that mints state is rate-limited, or it is a
+// way to fill a database from the internet.
+func TestCLIStartRateLimited(t *testing.T) {
+ fx := newConsoleFixture(t)
+ limited := false
+ for i := 0; i < cliStartBurst+3; i++ {
+ status, _ := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "x"}, "")
+ if status == http.StatusTooManyRequests {
+ limited = true
+ break
+ }
+ }
+ if !limited {
+ t.Fatalf("start was never rate-limited in %d attempts", cliStartBurst+3)
+ }
+}
+
+// Guessing the short typed code is the one brute-forceable step, so wrong
+// codes are limited too — per browser session, which is what an attacker who
+// is signed in as themselves actually has.
+func TestCLIApproveRateLimitsWrongCodes(t *testing.T) {
+ fx := newConsoleFixture(t)
+ browser := newBrowser(t)
+ signUp(t, fx, browser, "code-brute")
+ csrf := sessionToken(t, fx, browser)
+ limited := false
+ for i := 0; i < cliGuessBurst+3; i++ {
+ code, _, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/approve",
+ url.Values{"code": {"AAAA-AAAA"}, "action": {"approve"}, "csrf": {csrf}})
+ if code == http.StatusTooManyRequests {
+ limited = true
+ break
+ }
+ }
+ if !limited {
+ t.Fatalf("wrong codes were never rate-limited in %d attempts", cliGuessBurst+3)
+ }
+}
+
+func TestCLIPollUnknownDeviceCode(t *testing.T) {
+ fx := newConsoleFixture(t)
+ status, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": "nope"}, "")
+ if status != http.StatusOK || str(t, poll, "status") != CLIAuthUnknown {
+ t.Fatalf("poll with a made-up device code = %d %v, want 200 unknown", status, poll)
+ }
+}
+
+// The account a logged-in CLI creates has the logged-in human as its owner —
+// the whole reason this feature exists. An ownerless account was invisible to
+// its own creator in the console.
+func TestCLICreatesAccountOwnedByTheToken(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-acct")
+
+ status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "Kass"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("accounts create = %d %v", status, out)
+ }
+ sqid := str(t, out, "sqid")
+ if sqid == "" || str(t, out, "name") != "Kass" || str(t, out, "owner") != "paul@keymail.dev" {
+ t.Fatalf("accounts create returned %v", out)
+ }
+ acct, err := fx.st.AccountBySqid(sqid)
+ if err != nil || acct == nil {
+ t.Fatalf("AccountBySqid(%s) = %v %v", sqid, acct, err)
+ }
+ role, err := fx.st.Role(acct.ID, "paul@keymail.dev")
+ if err != nil || role != RoleOwner {
+ t.Fatalf("role = %q %v, want owner", role, err)
+ }
+ // The manifest is in the bucket too — the account exists for every box,
+ // not just for this console's database.
+ if _, _, err := fx.rel.Get(t.Context(), AccountManifestKey(sqid)); err != nil {
+ t.Fatalf("account manifest: %v", err)
+ }
+ // And an unauthenticated caller mints nothing.
+ if status, _ := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "Sneaky"}, ""); status != http.StatusUnauthorized {
+ t.Fatalf("unauthenticated accounts create = %d, want 401", status)
+ }
+ if status, _ := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": ""}, token); status != http.StatusBadRequest {
+ t.Fatalf("nameless accounts create = %d, want 400", status)
+ }
+}
+
+// `carlos auth logout` retires the credential rather than just forgetting it.
+func TestCLILogoutRevokes(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-logout")
+ if status, _ := postJSON(t, fx.ts.URL+"/auth/cli/revoke", map[string]string{}, token); status != http.StatusOK {
+ t.Fatalf("revoke = %d, want 200", status)
+ }
+ if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token); status != http.StatusUnauthorized {
+ t.Fatalf("whoami after logout = %d, want 401", status)
+ }
+ // Revoking an already-dead token is not an error worth surfacing, but it
+ // must not be an authenticated action either.
+ if status, _ := postJSON(t, fx.ts.URL+"/auth/cli/revoke", map[string]string{}, token); status != http.StatusUnauthorized {
+ t.Fatalf("second revoke = %d, want 401", status)
+ }
+}
+
+// cliToken walks the whole approval ceremony and hands back a live token, for
+// tests whose subject is what happens AFTER logging in.
+func cliToken(t *testing.T, fx *consoleFixture, address, oauthCode string) string {
+ t.Helper()
+ browser := newBrowser(t)
+ fx.setUser(address)
+ signUp(t, fx, browser, oauthCode)
+ _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "test@harness"}, "")
+ userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code")
+ if code, _, body := postForm(t, browser, fx.ts.URL+"/auth/cli/approve",
+ url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {sessionToken(t, fx, browser)}}); code != http.StatusOK {
+ t.Fatalf("approve = %d:\n%s", code, body)
+ }
+ _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, "")
+ tok := str(t, poll, "token")
+ if tok == "" {
+ t.Fatalf("no token from poll: %v", poll)
+ }
+ return tok
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 011662a43..3fc369884 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -110,7 +110,8 @@ type Server struct {
km *Keymail
mux *http.ServeMux
instances *instanceFetcher
- secure bool // Secure cookies iff the origin is https
+ secure bool // Secure cookies iff the origin is https
+ cli *cliLimits // rate limits on the CLI-identity endpoints (authcli.go)
}
// NewServer wires a server over an opened Store, loading (or minting) the
@@ -130,6 +131,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
mux: http.NewServeMux(),
instances: newInstanceFetcher(cfg.AppClient, cfg.AppScheme),
secure: strings.HasPrefix(cfg.Origin, "https://"),
+ cli: newCLILimits(),
}
s.mux.HandleFunc("GET /{$}", s.handleHome)
s.mux.HandleFunc("POST /auth/continue", s.handleContinue)
@@ -138,6 +140,9 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /auth/confirm", s.handleConfirm)
s.mux.HandleFunc("POST /auth/signout", s.handleSignout)
s.mux.HandleFunc("GET /fleet", s.handleFleet)
+ // CLI identity: the device-code login `carlos auth login` drives, the
+ // approval page, and the tokens an owner can see and kill (authcli.go).
+ s.registerCLIRoutes()
// Account-scoped and explicit: /a/<sqid>/apps/<app> rather than
// account-by-session, so a link a teammate opens lands where you meant.
s.mux.HandleFunc("GET /a/{sqid}/{$}", s.handleAccountHome)
diff --git a/internal/console/templates/cli_approve.html b/internal/console/templates/cli_approve.html
new file mode 100644
index 000000000..3268452b2
--- /dev/null
+++ b/internal/console/templates/cli_approve.html
@@ -0,0 +1,47 @@
+{{define "cli_approve"}}{{template "top" .}}
+<h1>Approve a terminal</h1>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+{{if .Request}}
+<p class="lede">A command line asked to sign in as you.</p>
+<div class="banner">
+ <p><strong>Code</strong> <code>{{.Request.UserCode}}</code></p>
+ <p><strong>Terminal</strong> {{emptyOrDash .Request.Label}}</p>
+ <p><strong>From</strong> {{emptyOrDash .Request.RemoteAddr}}</p>
+ <p class="small">Expires {{fmtTime .Request.ExpiresAt}}</p>
+</div>
+<p>Approve only if you just ran <code>carlos auth login</code> yourself, and
+the code above is the one your terminal printed. Approving gives that terminal
+your identity — every account you belong to — for {{.Days}} days. You can
+revoke it at any time from <a href="/auth/cli/tokens">command-line access</a>.</p>
+<form class="signin" method="post" action="/auth/cli/approve">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="hidden" name="code" value="{{.Request.UserCode}}">
+ <button type="submit" name="action" value="approve">Approve</button>
+ <button class="linkish" type="submit" name="action" value="deny">That wasn't me</button>
+</form>
+{{else}}
+<p class="lede">Type the code your terminal printed.</p>
+<form class="signin" method="post" action="/auth/cli/approve">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="text" name="code" value="{{.Code}}" placeholder="XXXX-XXXX" autocomplete="off" spellcheck="false">
+ <button type="submit" name="action" value="approve">Continue</button>
+</form>
+<p class="small">Codes look like <code>WXYZ-4823</code> and last ten minutes.
+Nothing here can be approved without one, and approving a code you did not
+generate hands your identity to whoever did.</p>
+{{end}}
+{{template "bottom" .}}{{end}}
+
+{{define "cli_decided"}}{{template "top" .}}
+<h1>{{.Title}}</h1>
+{{if eq .Title "Approved"}}
+<p>Your terminal has the credential — it should say so within a couple of
+seconds. It expires in {{.Days}} days.</p>
+<p><a href="/auth/cli/tokens">See every terminal signed in as you</a>, and
+revoke any you don't recognise.</p>
+{{else}}
+<p>Nothing was issued. That login attempt is dead and cannot be approved
+afterwards; if it wasn't yours, whoever started it got nothing.</p>
+{{end}}
+<p class="small"><a href="/">Back to the console</a></p>
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/cli_tokens.html b/internal/console/templates/cli_tokens.html
new file mode 100644
index 000000000..f44eec201
--- /dev/null
+++ b/internal/console/templates/cli_tokens.html
@@ -0,0 +1,35 @@
+{{define "cli_tokens"}}{{template "top" .}}
+<h1>Command-line access</h1>
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="error">{{.FlashErr}}</p>{{end}}
+<p class="lede">Every terminal that can act as {{.Address}}.</p>
+{{if .Tokens}}
+<div class="tablewrap">
+<table>
+ <tr><th>Terminal</th><th>Signed in</th><th>Last used</th><th>Expires</th><th></th></tr>
+ {{range .Tokens}}
+ <tr>
+ <td>{{emptyOrDash .Label}}</td>
+ <td>{{fmtTime .CreatedAt}}</td>
+ <td>{{if .LastUsed.IsZero}}—{{else}}{{fmtTime .LastUsed}}{{end}}</td>
+ <td>{{fmtTime .ExpiresAt}}</td>
+ <td>
+ <form method="post" action="/auth/cli/tokens/revoke">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="id" value="{{.ID}}">
+ <button class="linkish" type="submit">Revoke</button>
+ </form>
+ </td>
+ </tr>
+ {{end}}
+</table>
+</div>
+<p class="small">Revoking is immediate: that terminal's next command fails and
+it has to log in again. Tokens expire on their own after {{.Days}} days.</p>
+{{else}}
+<p>No terminals are signed in as you.</p>
+<pre><code>carlos auth login -console {{.Console}}</code></pre>
+<p class="small">Run that on the machine you want to deploy from. It prints a
+code; you approve it here.</p>
+{{end}}
+{{template "bottom" .}}{{end}}