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>
5 files changed,
+916
−1
internal/console/authcli.go+494 −0internal/console/authcli_test.go+334 −0internal/console/server.go+6 −1internal/console/templates/cli_approve.html+47 −0internal/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}} |