carlos / platform Public

merge: carlos auth login — the terminal gets an identity

Device grant against the deployment's own console (no second Keymail
OAuth client): printed code, poll, token minted at collection not
approval, bound to the approving keymail address, hashed at rest,
constant-time compared, 90 days, revocable.

An adversarial review found no bypass but three real weaknesses, all
fixed before merge: GET /auth/cli no longer resolves a prefilled code
(killing both one-click phishing and a code-enumeration oracle), the
rate limiter is bounded and keyed on inputs an attacker cannot rotate,
and issuing a token now sends a sealed Keymail notice to the approver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	README.md
#	accounts.go
#	e2e_test.go
#	internal/console/server.go
#	internal/console/store.go
#	internal/domains/mint.go
Paul Campbell cecc01a7936b4e3a505ca095de6c882a8b2c17c8 parent abd6bcbparent 98a4fa9
17 files changed, +3449 −85
  • README.md +42 −1
  • accounts.go +110 −76
  • auth.go +665 −0
  • auth_e2e_test.go +199 −0
  • auth_test.go +359 −0
  • internal/console/authcli.go +671 −0
  • internal/console/authcli_test.go +464 −0
  • internal/console/authowner_test.go +133 −0
  • internal/console/authstore.go +444 −0
  • internal/console/authstore_test.go +211 −0
  • internal/console/materialize.go +23 −0
  • internal/console/server.go +10 −1
  • internal/console/store.go +14 −4
  • internal/console/templates/cli_approve.html +59 −0
  • internal/console/templates/cli_tokens.html +38 −0
  • internal/domains/mint.go +3 −3
  • main.go +4 −0
diff --git a/README.md b/README.md
index 72dd3b053..883ce54b2 100644
--- a/README.md
+++ b/README.md
@@ -75,11 +75,52 @@ Clicking it while signed in as that address creates the membership.
**From the CLI.** Every app verb takes `-account`, a sqid or a name,
defaulting to `ops`:
- carlos accounts create -name Kass -owner paul@keymail.dev
+ carlos auth login -console https://platform.carlosframework.com
+ carlos accounts create -name Kass # you own it
+ carlos accounts create -name Kass -owner paul@keymail.dev # headless
carlos accounts list # sqid, name, app count
carlos ship -account kass -app blog ...
carlos add -account kass -app blog blog.example.com
+**Who the CLI is.** `carlos auth login` gives the command line an identity,
+and that identity owns the accounts it creates. It authenticates against the
+**deployment's own console** — already a registered Keymail OAuth client and
+already the authority on who belongs to what — rather than minting a second
+OAuth client, so there is one consent screen, one place to revoke, and a
+self-hoster's CLI trusts their own box.
+
+It prints a code rather than opening a browser or listening on a local port,
+because the machine that needs the credential is usually an SSH session where
+neither works. You open `/auth/cli` in whatever browser you are already signed
+in on and **type the code** the terminal printed.
+
+Typing it is the point, not a nicety: a link that arrives one click from an
+Approve button is the device-code phishing attack (the one run at scale against
+Microsoft tenants) — an attacker starts the flow, sends you their link, and one
+click hands them a credential that acts as you. So the console never approves
+from a prefilled code, and the CLI never prints the ready-made link. When a
+token is issued, a note goes to your Keymail inbox naming the terminal, where
+it connected from, and how to revoke it.
+
+The token lands in `~/.carlos/credentials` (mode 0600, one entry per console
+origin), expires in 90 days, and is listed with a revoke button at
+`/auth/cli/tokens`.
+
+ carlos auth whoami # who this terminal is, per the console
+ carlos auth logout # revokes the token, then forgets it
+
+**On a box, or in CI, don't log in.** There is no browser and no human, and a
+long-lived token sitting on a box is a standing credential nobody is watching.
+Name the owner outright instead — it needs no identity of its own and takes
+precedence over any logged-in one:
+
+ carlos accounts create -name Kass -owner paul@keymail.dev
+
+Either way an owner is named. `carlos accounts create` **refuses** with no
+login and no `-owner`: an account with no owner materializes into the console
+with no members, which means nobody can open it, invite to it, or see the apps
+migrated into it.
+
An exact sqid wins; otherwise a unique name match; an ambiguous name lists the
candidates and refuses rather than guessing which tenant you meant.
diff --git a/accounts.go b/accounts.go
index 586e4f288..95a586843 100644
--- a/accounts.go
+++ b/accounts.go
@@ -17,6 +17,7 @@ import (
"flag"
"fmt"
"log"
+ "net/http"
"os"
"strings"
"text/tabwriter"
@@ -30,12 +31,14 @@ import (
const accountsUsage = `usage: carlos accounts <create|list|migrate>
create -name <name> mint an account: the next free id, its
- -owner <address> frozen sqid, and the bucket manifest every
- box materializes identity from. -owner is
- required and is recorded in the manifest —
- the console materializes it into the first
- owner membership, because an account with no
- members cannot be reached at all
+ frozen sqid, and the bucket manifest every
+ box materializes identity from
+ [-owner <address>] who owns it. On a box or in CI — no browser,
+ no login — this is the path: it needs no
+ identity of its own and takes precedence
+ over any logged-in one.
+ [-console <url>] which logged-in console to mint through
+ (only needed with several)
list sqid, name, and app count
migrate -app <a> -to <sqid> copy an app's objects to the account-
qualified prefix and re-stamp its routes
@@ -43,6 +46,10 @@ const accountsUsage = `usage: carlos accounts <create|list|migrate>
account-less keys)
[-dry-run] print the plan and touch nothing
+create always names an owner, one way or the other: log this terminal in with
+"carlos auth login" and you become the owner, or pass -owner. It refuses
+rather than mint an account nobody can open in the console.
+
migrate never deletes a legacy object. Deleting them is a separate operator
step, behind the verification gate: every channel pointer must resolve from
the qualified keys with the fallback disabled, and one adopt pass must be a
@@ -68,94 +75,121 @@ func RunAccounts(args []string) {
}
}
-// runAccountsCreate mints an account — and, since a live failure, never an
-// OWNERLESS one.
-//
-// What happened, in production, migrating the flagship: this verb wrote the
-// bucket manifest and the registry row and recorded an owner nowhere at all.
-// Membership IS authorization in the accounts model, so after kass and keymail
-// were migrated into their new accounts the operator could not reach either of
-// them in the console. The accounts existed, held the apps, had no members, and
-// nothing in the output said what to do about it — the only door was a `console
-// member -add <address> <sqid> -role owner` somebody had to already know.
-//
-// So -owner is REQUIRED. Not defaulted, not inferred from the invoking shell
-// user: an account's first owner can promote and roll back everything in it,
-// and guessing who that is would be minting authority. Missing means refusing,
-// by name, before anything is written.
-//
-// Where the owner is RECORDED is the interesting half. The carlos binary must
-// not open the console's database — that boundary is deliberate and was
-// enforced in an earlier review; console state is the console's authority,
-// exactly like grants. So the owner goes into the account MANIFEST, which is
-// already the one object account identity crosses on, and the console's own
-// materialization pass turns it into an owner membership
-// (console.MaterializeAccounts → Store.AdoptManifestOwner). The bucket is the
-// control plane, so a CLI-created account and a console-created one converge on
-// the same end state instead of on two different ones. Printing a follow-up
-// command instead would have left the same gap the operator just fell into: a
-// step that only works if somebody runs it.
func runAccountsCreate(args []string) {
fs := flag.NewFlagSet("accounts create", flag.ExitOnError)
regPath := fs.String("registry", defaultRegistry(), "registry database")
name := fs.String("name", "", "account name (free text, need not be unique)")
- owner := fs.String("owner", "", "keymail address of the account's first owner (required)")
+ owner := fs.String("owner", "", "keymail address that owns the account — the box-side/headless path, "+
+ "and what to use when this terminal is not logged in")
+ console := fs.String("console", "", "which logged-in console to create it through (only needed with several)")
fs.Parse(args)
if strings.TrimSpace(*name) == "" {
- log.Fatal("usage: carlos accounts create -name <name> -owner <address>")
- }
- // Normalized and shape-checked with the console's own rule
- // (domains.ValidAddress, which console.ValidAddress delegates to), so an
- // address this verb accepts is one the sign-in form accepts too. The
- // memberships key is literal text with no COLLATE NOCASE: an owner recorded
- // as "Paul@Keymail.dev" would be a membership the real session never
- // matches — recorded, and still unable to get in.
- ownerAddr := domains.NormalizeAddress(*owner)
- if ownerAddr == "" {
- log.Fatal("accounts create: -owner <address> is required.\n" +
- "An account with no owner is unreachable through the console — membership IS\n" +
- "authorization, so an account with no members has no door, whatever apps are in it.\n" +
- " carlos accounts create -name <name> -owner <address>")
- }
- if !domains.ValidAddress(ownerAddr) {
- log.Fatalf("accounts create: %q is not a keymail address — expected <user>@<host>, e.g. paul@keymail.dev", *owner)
+ log.Fatal("usage: carlos accounts create -name <name> [-owner <address>]")
+ }
+
+ // An ownerless account is the failure this whole path exists to prevent:
+ // it materializes into the console with no members, which means nobody can
+ // open it, invite anyone to it, or see the apps migrated into it. So there
+ // are exactly two ways to name an owner and no way to skip it.
+ //
+ // -owner WINS over the logged-in identity, deliberately: it is the explicit
+ // instruction, it is the documented headless path, and an operator creating
+ // an account FOR somebody else must not have their own address quietly
+ // substituted.
+ if addr := strings.TrimSpace(*owner); addr != "" {
+ createAccountInBucket(*regPath, *name, addr)
+ return
}
+ cred, credErr := loggedInCredential(*console)
+ if credErr != nil {
+ log.Fatalf("accounts create: this account would have no owner, and an ownerless account is one\n"+
+ "nobody can open in the console. Name one, either way:\n\n"+
+ " carlos auth login -console <url> log this terminal in; you become the owner\n"+
+ " carlos accounts create -owner <address> -name %q\n"+
+ " name the owner outright — the box-side and\n"+
+ " headless path, no browser or login needed\n\n"+
+ "(%v)", *name, credErr)
+ }
+ createAccountViaConsole(cred, *regPath, *name)
+}
+
+// createAccountInBucket is the original path, plus an owner: mint the id with
+// a create-only manifest write, then materialize the registry row.
+//
+// The bucket first, and not just for ordering: the manifest write IS the
+// allocator. domains.MintAccount picks the next free integer and claims it
+// with a create-only Put, so this CLI and a console minting at the same moment
+// cannot land on the same id — and the registry, which only ever materializes
+// what the bucket says, never has to assign anything. The owner rides along in
+// that same manifest, which is how the console learns who to admit.
+func createAccountInBucket(regPath, name, owner string) {
+ // Normalize before anything is written OR printed. MintAccount normalizes
+ // what it stores, so printing the raw input would tell the operator their
+ // membership is "Paul@Keymail.dev" when the row — and the session that must
+ // match it — is lowercase. That mismatch is exactly the confusion the
+ // `console member` verb was fixed for.
+ owner = domains.NormalizeAddress(owner)
store := openStore()
- // The bucket first, and not just for ordering: the manifest write IS the
- // allocator. domains.MintAccount picks the next free integer and claims it
- // with a create-only Put, so this CLI and a console minting at the same
- // moment cannot land on the same id — and the registry, which only ever
- // materializes what the bucket says, never has to assign anything.
- id, sqid, err := domains.MintAccount(context.Background(), store, *name, ownerAddr, time.Now())
+ id, sqid, err := domains.MintAccount(context.Background(), store, name, owner, time.Now())
if err != nil {
log.Fatalf("accounts create: %v", err)
}
- r, err := registry.Open(*regPath)
+ r, err := registry.Open(regPath)
if err != nil {
log.Fatal(err)
}
defer r.Close()
- if err := r.PutAccount(id, sqid, *name); err != nil {
+ if err := r.PutAccount(id, sqid, name); err != nil {
log.Fatalf("accounts create: materialize: %v", err)
}
- fmt.Printf("created account %q\n id: %d\n sqid: %s\n owner: %s\n", *name, id, sqid, ownerAddr)
+ fmt.Printf("created account %q\n id: %d\n sqid: %s\n owner: %s\n", name, id, sqid, owner)
fmt.Printf("use it with: carlos ship -account %s -app <app> ...\n", sqid)
- // Said out loud because the ordering is not obvious and the operator has
- // just been bitten by its absence: the membership appears when the console
- // next reads the bucket, not when this command returns.
- //
- // "the FIRST time" is exact, not loose wording. The console adopts a
- // manifest's owner only on the pass that first creates its own row for the
- // account, because the manifest is not write-once — anyone with bucket write
- // access can rewrite it, and adopting on later passes would turn that into a
- // standing authority grant needing no signing key. Editing this field
- // afterwards will NOT move ownership; `console member -add` is the verb for
- // that, and it is box-side for exactly that reason.
- fmt.Printf("\n%s becomes an owner the first time the console materializes this account\n"+
- "from the bucket (on start, and before every `console member` command). Nothing\n"+
- "else to run. Editing the manifest's owner LATER grants nothing — use\n"+
- "`console member -add <address> %s -role owner` to change ownership after this.\n",
- ownerAddr, sqid)
+ fmt.Printf("%s becomes its owner in the console the next time the console materializes\n"+
+ "accounts from the bucket (on start, or on any `console member` command).\n", owner)
+}
+
+// createAccountViaConsole mints through the console this terminal is logged in
+// to, which makes the logged-in human the owner in one step.
+//
+// Through the console rather than straight into the bucket because the console
+// is the identity authority: it writes the manifest AND the owner membership in
+// its own database in one path, so the account is openable by its creator the
+// instant it exists — rather than "once something materializes it", which is the
+// gap that produced accounts nobody could reach.
+func createAccountViaConsole(cred *credential, regPath, name string) {
+ if warn := credentialPermWarning(); warn != "" {
+ fmt.Fprintln(os.Stderr, warn)
+ }
+ c := newConsoleClient(cred.Console, cred.Token)
+ var out struct {
+ ID int64 `json:"id"`
+ Sqid string `json:"sqid"`
+ Name string `json:"name"`
+ Owner string `json:"owner"`
+ URL string `json:"url"`
+ }
+ if _, err := c.do(context.Background(), http.MethodPost, "/auth/cli/accounts",
+ map[string]string{"name": name}, &out); err != nil {
+ log.Fatalf("accounts create: %s: %v", cred.Console, err)
+ }
+ fmt.Printf("created account %q\n id: %d\n sqid: %s\n owner: %s (you)\n", out.Name, out.ID, out.Sqid, out.Owner)
+ fmt.Printf(" open: %s\n", out.URL)
+ fmt.Printf("use it with: carlos ship -account %s -app <app> ...\n", out.Sqid)
+ // The registry row is this box's local materialization of what the bucket
+ // already says. Best-effort, unlike the bucket-mint path: the account
+ // exists and is owned no matter what happens here, and a build machine
+ // with no registry at all is a perfectly normal place to run this.
+ r, err := registry.Open(regPath)
+ if err != nil {
+ fmt.Printf("\n(no registry at %s, so nothing was materialized locally: %v —\n"+
+ "harmless: the bucket is the control plane and every box reconciles from it.)\n", regPath, err)
+ return
+ }
+ defer r.Close()
+ if err := r.PutAccount(out.ID, out.Sqid, out.Name); err != nil {
+ fmt.Printf("\n(could not materialize the registry row here: %v — harmless: the account\n"+
+ "exists in the bucket and every box reconciles from it.)\n", err)
+ }
}
func runAccountsList(args []string) {
diff --git a/auth.go b/auth.go
new file mode 100644
index 000000000..6e3d42c37
--- /dev/null
+++ b/auth.go
@@ -0,0 +1,665 @@
+// 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"
+ "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)
+ }
+ // http is for a console on this machine and nowhere else. Over http the
+ // login flow hands a 90-day bearer token to anyone on the path, and a
+ // typo'd or attacker-supplied http:// origin is a credential posted in
+ // clear text to a stranger.
+ if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) {
+ return "", fmt.Errorf("%q is http:// but not local — a login token would cross the network in clear text. Use https://", in)
+ }
+ return u.Scheme + "://" + u.Host, nil
+}
+
+// isLoopbackHost reports whether a URL host is this machine.
+func isLoopbackHost(host string) bool {
+ if host == "localhost" {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
+
+// ---- talking to the console -------------------------------------------------
+
+// consoleClient is the CLI's half of the console's JSON API.
+type consoleClient struct {
+ base string
+ token string
+ httpc *http.Client
+}
+
+func newConsoleClient(base, token string) *consoleClient {
+ 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
+}
+
+// Bounds on what a console can talk this process into doing. It is a console
+// we are about to trust with our identity, so this is not a threat model so
+// much as a refusal to turn two JSON fields into a CPU burner: a zero interval
+// is a hot loop, and an enormous expires_in is a hot loop that never ends.
+const (
+ minPollWait = time.Second
+ maxPollWait = 30 * time.Second
+ maxLoginWait = 15 * time.Minute
+)
+
+// pollWait bounds the sleep between polls, floor included: interval 0 must
+// never mean "spin".
+func pollWait(seconds int) time.Duration {
+ d := time.Duration(seconds) * time.Second
+ if d < minPollWait {
+ return minPollWait
+ }
+ if d > maxPollWait {
+ return maxPollWait
+ }
+ return d
+}
+
+// loginDeadline bounds how long the CLI waits overall, whatever expires_in says.
+func loginDeadline(now time.Time, expiresIn int) time.Time {
+ d := time.Duration(expiresIn) * time.Second
+ if d <= 0 || d > maxLoginWait {
+ d = maxLoginWait
+ }
+ return now.Add(d)
+}
+
+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)
+ }
+ // The BARE verification URI plus the code, never the ready-made
+ // ?code=… link, even though the console offers one.
+ //
+ // A link that lands one click from an Approve button is the whole
+ // device-code phishing attack: an attacker starts this flow, sends you
+ // their link, and one click hands them a 90-day credential that acts as
+ // you. The console refuses to approve from a prefilled code for that
+ // reason, and printing the complete URL here would teach people that
+ // following such a link is the normal way to do this — which is exactly
+ // the habit the attack needs. Type the code.
+ link := start.VerifyURI
+ if link == "" {
+ link = base + "/auth/cli"
+ }
+ // Printed, never opened: this frequently runs over SSH, where opening a
+ // browser would open it on the wrong machine.
+ fmt.Fprintf(out, "\nOpen this in a browser signed in to %s:\n\n %s\n\n", base, link)
+ fmt.Fprintf(out, "and type this code:\n\n %s\n\n", start.UserCode)
+ fmt.Fprintf(out, "Waiting for approval (this code expires in %d minutes)…\n", start.ExpiresIn/60)
+
+ deadline := loginDeadline(time.Now(), start.ExpiresIn)
+ wait := pollWait(start.Interval)
+ for time.Now().Before(deadline) {
+ time.Sleep(wait)
+ var poll pollResponse
+ if _, err := c.do(ctx, http.MethodPost, "/auth/cli/poll",
+ map[string]string{"device_code": start.DeviceCode}, &poll); err != nil {
+ // A hiccup mid-poll is not a failed login: keep waiting, the
+ // human is mid-approval.
+ continue
+ }
+ if poll.Interval > 0 {
+ wait = pollWait(poll.Interval)
+ }
+ switch poll.Status {
+ case "pending":
+ continue
+ case "denied":
+ return errors.New("the console says that login was denied — nothing was issued")
+ case "expired":
+ return errors.New("that login code expired before it was approved — run this again")
+ case "unknown":
+ return errors.New("the console does not recognise this login — run this again")
+ case "approved":
+ if poll.Token == "" {
+ return errors.New("the console approved the login but returned no token")
+ }
+ now := time.Now().UTC()
+ expires := now.Add(90 * 24 * time.Hour)
+ if poll.ExpiresAt != "" {
+ if t, err := time.Parse(time.RFC3339, poll.ExpiresAt); err == nil {
+ expires = t
+ }
+ }
+ f, err := loadCredentials()
+ if err != nil {
+ return err
+ }
+ f.put(credential{Console: base, Address: poll.Address, Token: poll.Token,
+ Label: *label, CreatedAt: now, ExpiresAt: expires})
+ if err := f.save(); err != nil {
+ return fmt.Errorf("storing the credential: %w", err)
+ }
+ // Checked here as well as in whoami: this is the moment a bearer
+ // token first exists on this machine, and a pre-existing file with
+ // loose permissions is exactly when to say so.
+ if warn := credentialPermWarning(); warn != "" {
+ fmt.Fprintln(out, warn)
+ }
+ 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)
+}
diff --git a/auth_e2e_test.go b/auth_e2e_test.go
new file mode 100644
index 000000000..2fadb189f
--- /dev/null
+++ b/auth_e2e_test.go
@@ -0,0 +1,199 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// buildCarlos builds the real binary, the way TestStatusCLI does, so these
+// tests exercise the actual command line an operator types rather than the
+// functions underneath it.
+func buildCarlos(t *testing.T) string {
+ t.Helper()
+ goBin, err := exec.LookPath("go")
+ if err != nil {
+ t.Skip("go toolchain not found; skipping CLI subprocess test")
+ }
+ bin := filepath.Join(t.TempDir(), "carlos")
+ build := exec.Command(goBin, "build", "-o", bin, ".")
+ build.Env = os.Environ()
+ if out, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build carlos: %v\n%s", err, out)
+ }
+ return bin
+}
+
+// TestAuthCLI drives `carlos auth` as a subprocess against a fake console:
+// login stores a credential the file system can be checked for, whoami reads
+// it back, and logout revokes it.
+func TestAuthCLI(t *testing.T) {
+ bin := buildCarlos(t)
+ fc := newFakeConsole(t)
+ home := t.TempDir()
+ creds := filepath.Join(home, ".carlos", "credentials")
+ env := append(os.Environ(), "CARLOS_CREDENTIALS="+creds)
+
+ run := func(args ...string) (string, error) {
+ cmd := exec.Command(bin, args...)
+ cmd.Env = env
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ // A login with no console at all refuses, and names both ways out.
+ out, err := run("auth", "login")
+ if err == nil {
+ t.Fatalf("carlos auth login with no -console exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "-console") || !strings.Contains(out, "-owner") {
+ t.Fatalf("refusal does not name both options:\n%s", out)
+ }
+
+ fc.approved.Store(true)
+ out, err = run("auth", "login", "-console", fc.ts.URL, "-label", "e2e@box")
+ if err != nil {
+ t.Fatalf("carlos auth login: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "WXYZ-4823") || !strings.Contains(out, "paul@keymail.dev") {
+ t.Fatalf("login output:\n%s", out)
+ }
+ if strings.Contains(out, "cli-token") {
+ t.Fatalf("login printed the bearer token:\n%s", out)
+ }
+ fi, err := os.Stat(creds)
+ if err != nil {
+ t.Fatalf("credentials file: %v", err)
+ }
+ if fi.Mode().Perm() != 0o600 {
+ t.Fatalf("credentials mode = %v, want 0600", fi.Mode().Perm())
+ }
+
+ // whoami with no -console picks the only credential there is.
+ out, err = run("auth", "whoami")
+ if err != nil {
+ t.Fatalf("carlos auth whoami: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "paul@keymail.dev") {
+ t.Fatalf("whoami output:\n%s", out)
+ }
+
+ out, err = run("auth", "logout")
+ if err != nil {
+ t.Fatalf("carlos auth logout: %v\n%s", err, out)
+ }
+ if !fc.revoked.Load() {
+ t.Fatal("logout did not revoke at the console")
+ }
+ if out, err := run("auth", "whoami"); err == nil {
+ t.Fatalf("whoami after logout exited 0:\n%s", out)
+ }
+}
+
+// TestAccountsCreateNeedsAnOwner is the regression this whole feature exists
+// for: an account with no owner is one nobody can open in the console, so the
+// CLI refuses to mint one and names both ways to fix it.
+func TestAccountsCreateNeedsAnOwner(t *testing.T) {
+ bin := buildCarlos(t)
+ tmp := t.TempDir()
+ env := append(os.Environ(),
+ "CARLOS_DEPLOYMENT_DIR="+filepath.Join(tmp, "store"),
+ "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"),
+ "CARLOS_DATA_DIR="+tmp,
+ )
+ cmd := exec.Command(bin, "accounts", "create", "-name", "Orphan")
+ cmd.Env = env
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ t.Fatalf("ownerless accounts create exited 0:\n%s", out)
+ }
+ for _, want := range []string{"carlos auth login", "-owner"} {
+ if !strings.Contains(string(out), want) {
+ t.Fatalf("refusal does not name %q:\n%s", want, out)
+ }
+ }
+ // Nothing was minted: a refusal must not leave a half-made account behind.
+ if keys, err := release.NewDirStore(filepath.Join(tmp, "store")).List(t.Context(), "accounts/"); err != nil || len(keys) != 0 {
+ t.Fatalf("a refused create left %v behind (%v)", keys, err)
+ }
+}
+
+// The headless path: -owner names the owner outright, no login, no browser,
+// and the address lands in the manifest every box materializes identity from.
+func TestAccountsCreateWithOwnerFlag(t *testing.T) {
+ bin := buildCarlos(t)
+ tmp := t.TempDir()
+ storeDir := filepath.Join(tmp, "store")
+ cmd := exec.Command(bin, "accounts", "create", "-name", "Kass", "-owner", "paul@keymail.dev")
+ cmd.Env = append(os.Environ(),
+ "CARLOS_DEPLOYMENT_DIR="+storeDir,
+ "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"),
+ "CARLOS_DATA_DIR="+tmp,
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("carlos accounts create -owner: %v\n%s", err, out)
+ }
+ if !strings.Contains(string(out), "paul@keymail.dev") {
+ t.Fatalf("create output does not name the owner:\n%s", out)
+ }
+ store := release.NewDirStore(storeDir)
+ keys, err := store.List(t.Context(), "accounts/")
+ if err != nil || len(keys) != 1 {
+ t.Fatalf("account manifests = %v (%v), want exactly one", keys, err)
+ }
+ body, _, err := store.Get(t.Context(), keys[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m domains.AccountManifest
+ if err := json.Unmarshal(body, &m); err != nil {
+ t.Fatal(err)
+ }
+ if m.Owner != "paul@keymail.dev" || m.Name != "Kass" {
+ t.Fatalf("manifest = %+v, want Kass owned by paul", m)
+ }
+}
+
+// Logged in, no -owner: the account is created THROUGH the console, so the
+// person at the terminal is its owner from the first instant.
+func TestAccountsCreateUsesTheLoggedInIdentity(t *testing.T) {
+ bin := buildCarlos(t)
+ fc := newFakeConsole(t)
+ tmp := t.TempDir()
+ creds := filepath.Join(tmp, "credentials")
+ f := &credentialFile{Version: 1, Credentials: []credential{{
+ Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
+ CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().Add(24 * time.Hour).UTC(),
+ }}}
+ raw, err := json.Marshal(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(creds, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command(bin, "accounts", "create", "-name", "Kass")
+ cmd.Env = append(os.Environ(),
+ "CARLOS_CREDENTIALS="+creds,
+ "CARLOS_DATA_DIR="+tmp,
+ // Deliberately no CARLOS_DEPLOYMENT_DIR: the console owns the bucket
+ // write on this path, and a laptop has no deployment store configured.
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("carlos accounts create (logged in): %v\n%s", err, out)
+ }
+ for _, want := range []string{"bag", "paul@keymail.dev", "(you)"} {
+ if !strings.Contains(string(out), want) {
+ t.Fatalf("create output missing %q:\n%s", want, out)
+ }
+ }
+}
diff --git a/auth_test.go b/auth_test.go
new file mode 100644
index 000000000..ae735e5df
--- /dev/null
+++ b/auth_test.go
@@ -0,0 +1,359 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// useTempCredentials points the credentials file at a scratch path for the
+// length of one test.
+func useTempCredentials(t *testing.T) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "carlos", "credentials")
+ t.Setenv("CARLOS_CREDENTIALS", path)
+ return path
+}
+
+func TestCredentialsRoundTripAndPermissions(t *testing.T) {
+ path := useTempCredentials(t)
+
+ f, err := loadCredentials()
+ if err != nil {
+ t.Fatalf("loading a missing credentials file must be empty, not an error: %v", err)
+ }
+ if len(f.Credentials) != 0 {
+ t.Fatalf("fresh file = %v, want empty", f.Credentials)
+ }
+
+ now := time.Now().UTC().Truncate(time.Second)
+ f.put(credential{Console: "https://platform.carlosframework.com", Address: "paul@keymail.dev",
+ Token: "tok-flagship", CreatedAt: now, ExpiresAt: now.Add(time.Hour)})
+ f.put(credential{Console: "https://console.example.org", Address: "paul@self.host",
+ Token: "tok-self", CreatedAt: now, ExpiresAt: now.Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ // A bearer credential on disk is 0600, and the directory it lives in is
+ // not readable by anyone else either.
+ fi, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fi.Mode().Perm() != 0o600 {
+ t.Fatalf("credentials mode = %v, want 0600", fi.Mode().Perm())
+ }
+ di, err := os.Stat(filepath.Dir(path))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if di.Mode().Perm()&0o077 != 0 {
+ t.Fatalf("credentials dir mode = %v, want no group/other bits", di.Mode().Perm())
+ }
+
+ back, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(back.Credentials) != 2 {
+ t.Fatalf("reloaded %d credentials, want 2", len(back.Credentials))
+ }
+ // One entry per console origin: a laptop talks to the flagship AND a
+ // self-hosted deployment, and re-logging in replaces only the one.
+ back.put(credential{Console: "https://platform.carlosframework.com/", Address: "paul@keymail.dev",
+ Token: "tok-flagship-2", CreatedAt: now, ExpiresAt: now.Add(time.Hour)})
+ if len(back.Credentials) != 2 {
+ t.Fatalf("re-login added an entry: %d, want 2", len(back.Credentials))
+ }
+ if c := back.find("https://platform.carlosframework.com"); c == nil || c.Token != "tok-flagship-2" {
+ t.Fatalf("find after re-login = %+v, want the new token", c)
+ }
+ if c := back.find("https://console.example.org"); c == nil || c.Token != "tok-self" {
+ t.Fatal("re-logging in to one console disturbed another")
+ }
+ if !back.remove("https://console.example.org") || len(back.Credentials) != 1 {
+ t.Fatal("remove did not remove exactly one entry")
+ }
+ if back.remove("https://nobody.example") {
+ t.Fatal("removing an absent console reported success")
+ }
+}
+
+// With no -console flag the CLI picks the only credential it has, refuses to
+// guess between several, and says how to fix it when there are none.
+func TestCredentialSelection(t *testing.T) {
+ useTempCredentials(t)
+ f, _ := loadCredentials()
+ if _, err := f.pick(""); err == nil {
+ t.Fatal("picking from an empty credentials file must fail")
+ } else if !strings.Contains(err.Error(), "carlos auth login") {
+ t.Fatalf("empty-file error %q does not name the fix", err)
+ }
+ now := time.Now().UTC()
+ f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: now.Add(time.Hour)})
+ c, err := f.pick("")
+ if err != nil || c.Console != "https://a.example" {
+ t.Fatalf("pick with one credential = %+v %v", c, err)
+ }
+ f.put(credential{Console: "https://b.example", Address: "b@keymail.dev", Token: "t2", ExpiresAt: now.Add(time.Hour)})
+ if _, err := f.pick(""); err == nil || !strings.Contains(err.Error(), "-console") {
+ t.Fatalf("pick with two credentials = %v, want a refusal naming -console", err)
+ }
+ if c, err := f.pick("https://b.example/"); err != nil || c.Token != "t2" {
+ t.Fatalf("explicit pick = %+v %v", c, err)
+ }
+ if _, err := f.pick("https://c.example"); err == nil {
+ t.Fatal("picking a console we have no credential for must fail")
+ }
+ // An expired credential is not a credential.
+ f.put(credential{Console: "https://old.example", Address: "o@keymail.dev", Token: "t3", ExpiresAt: now.Add(-time.Minute)})
+ if _, err := f.pick("https://old.example"); err == nil || !strings.Contains(err.Error(), "expired") {
+ t.Fatalf("expired pick = %v, want an expiry refusal", err)
+ }
+}
+
+func TestNormalizeConsole(t *testing.T) {
+ for in, want := range map[string]string{
+ "https://platform.carlosframework.com/": "https://platform.carlosframework.com",
+ "https://platform.carlosframework.com": "https://platform.carlosframework.com",
+ "http://127.0.0.1:8090/": "http://127.0.0.1:8090",
+ " https://platform.carlosframework.com ": "https://platform.carlosframework.com",
+ } {
+ got, err := normalizeConsole(in)
+ if err != nil || got != want {
+ t.Errorf("normalizeConsole(%q) = %q %v, want %q", in, got, err, want)
+ }
+ }
+ for _, bad := range []string{"", "platform.carlosframework.com", "ftp://x", "https://"} {
+ if got, err := normalizeConsole(bad); err == nil {
+ t.Errorf("normalizeConsole(%q) = %q, want an error", bad, got)
+ }
+ }
+ // http is for a console on this machine and nowhere else: over the network
+ // it posts a 90-day bearer token in clear text.
+ for _, bad := range []string{"http://platform.carlosframework.com", "http://192.168.1.4:8090"} {
+ if got, err := normalizeConsole(bad); err == nil {
+ t.Errorf("normalizeConsole(%q) = %q, want a refusal — http off-box", bad, got)
+ }
+ }
+ for _, ok := range []string{"http://localhost:8090", "http://127.0.0.1:8090", "http://[::1]:8090"} {
+ if _, err := normalizeConsole(ok); err != nil {
+ t.Errorf("normalizeConsole(%q) = %v, want a local console to be allowed", ok, err)
+ }
+ }
+}
+
+// A console can ask a CLI to wait; it cannot talk it into a hot loop or an
+// endless one.
+func TestPollBoundsAreEnforced(t *testing.T) {
+ if got := pollWait(0); got < time.Second {
+ t.Fatalf("pollWait(0) = %v — interval 0 must not mean spin", got)
+ }
+ if got := pollWait(-5); got < time.Second {
+ t.Fatalf("pollWait(-5) = %v, want the floor", got)
+ }
+ if got := pollWait(99999); got > 30*time.Second {
+ t.Fatalf("pollWait(99999) = %v, want the cap", got)
+ }
+ now := time.Now()
+ if got := loginDeadline(now, 99999999); got.After(now.Add(maxLoginWait)) {
+ t.Fatalf("loginDeadline with an absurd expires_in = %v, want it capped", got.Sub(now))
+ }
+ if got := loginDeadline(now, 0); !got.After(now) {
+ t.Fatalf("loginDeadline with no expires_in = %v, want a sane default", got.Sub(now))
+ }
+}
+
+// fakeConsole answers the CLI endpoints the way the real console does, so the
+// login loop can be driven end to end without a browser.
+type fakeConsole struct {
+ ts *httptest.Server
+ polls atomic.Int32
+ approved atomic.Bool
+ denied atomic.Bool
+ revoked atomic.Bool
+ lastAuth atomic.Value // string: the bearer the last request carried
+ label atomic.Value // string
+}
+
+func newFakeConsole(t *testing.T) *fakeConsole {
+ t.Helper()
+ fc := &fakeConsole{}
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /auth/cli/start", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Content-Type") != "application/json" {
+ w.WriteHeader(http.StatusUnsupportedMediaType)
+ return
+ }
+ var req struct{ Label string }
+ json.NewDecoder(r.Body).Decode(&req)
+ fc.label.Store(req.Label)
+ json.NewEncoder(w).Encode(map[string]any{
+ "user_code": "WXYZ-4823", "device_code": "device-secret",
+ "verification_uri": fc.ts.URL + "/auth/cli",
+ "verification_uri_complete": fc.ts.URL + "/auth/cli?code=WXYZ-4823",
+ "interval": 0, "expires_in": 600,
+ })
+ })
+ mux.HandleFunc("POST /auth/cli/poll", func(w http.ResponseWriter, r *http.Request) {
+ n := fc.polls.Add(1)
+ switch {
+ case fc.denied.Load():
+ json.NewEncoder(w).Encode(map[string]any{"status": "denied", "interval": 0})
+ case fc.approved.Load() || n >= 2:
+ json.NewEncoder(w).Encode(map[string]any{
+ "status": "approved", "token": "cli-token", "address": "paul@keymail.dev",
+ "expires_at": time.Now().Add(90 * 24 * time.Hour).UTC().Format(time.RFC3339),
+ "console": fc.ts.URL,
+ })
+ default:
+ json.NewEncoder(w).Encode(map[string]any{"status": "pending", "interval": 0})
+ }
+ })
+ mux.HandleFunc("GET /auth/cli/whoami", func(w http.ResponseWriter, r *http.Request) {
+ fc.lastAuth.Store(r.Header.Get("Authorization"))
+ if r.Header.Get("Authorization") != "Bearer cli-token" || fc.revoked.Load() {
+ w.WriteHeader(http.StatusUnauthorized)
+ json.NewEncoder(w).Encode(map[string]string{"error": "not signed in"})
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "address": "paul@keymail.dev", "console": fc.ts.URL, "label": "paul@laptop",
+ "expires_at": time.Now().Add(90 * 24 * time.Hour).UTC().Format(time.RFC3339),
+ })
+ })
+ mux.HandleFunc("POST /auth/cli/revoke", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer cli-token" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ fc.revoked.Store(true)
+ json.NewEncoder(w).Encode(map[string]any{"revoked": true})
+ })
+ mux.HandleFunc("POST /auth/cli/accounts", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer cli-token" {
+ w.WriteHeader(http.StatusUnauthorized)
+ json.NewEncoder(w).Encode(map[string]string{"error": "not signed in"})
+ return
+ }
+ var req struct{ Name string }
+ json.NewDecoder(r.Body).Decode(&req)
+ json.NewEncoder(w).Encode(map[string]any{
+ "id": 7, "sqid": "bag", "name": req.Name, "owner": "paul@keymail.dev",
+ "url": fc.ts.URL + "/a/bag/",
+ })
+ })
+ fc.ts = httptest.NewServer(mux)
+ t.Cleanup(fc.ts.Close)
+ return fc
+}
+
+func TestAuthLoginStoresACredential(t *testing.T) {
+ useTempCredentials(t)
+ fc := newFakeConsole(t)
+ var out bytes.Buffer
+
+ if err := authLogin(&out, []string{"-console", fc.ts.URL, "-label", "paul@laptop"}); err != nil {
+ t.Fatalf("authLogin: %v\n%s", err, out.String())
+ }
+ // The human is told where to go and what to TYPE — that IS the flow.
+ for _, want := range []string{fc.ts.URL + "/auth/cli", "type this code", "WXYZ-4823", "paul@keymail.dev"} {
+ if !strings.Contains(out.String(), want) {
+ t.Fatalf("login output missing %q:\n%s", want, out.String())
+ }
+ }
+ // And never the ready-made ?code= link. Printing that would teach people
+ // that following a link somebody sent them is how this works, which is
+ // precisely the habit device-code phishing needs.
+ if strings.Contains(out.String(), "?code=") {
+ t.Fatalf("login printed a one-click approval link:\n%s", out.String())
+ }
+ if got, _ := fc.label.Load().(string); got != "paul@laptop" {
+ t.Fatalf("label sent = %q, want paul@laptop", got)
+ }
+ f, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := f.find(fc.ts.URL)
+ if c == nil || c.Token != "cli-token" || c.Address != "paul@keymail.dev" {
+ t.Fatalf("stored credential = %+v", c)
+ }
+ // The token is a secret: it must not be printed.
+ if strings.Contains(out.String(), "cli-token") {
+ t.Fatalf("login printed the token:\n%s", out.String())
+ }
+
+ // whoami asks the console, and says who and where.
+ out.Reset()
+ if err := authWhoami(&out, []string{"-console", fc.ts.URL}); err != nil {
+ t.Fatalf("authWhoami: %v", err)
+ }
+ if !strings.Contains(out.String(), "paul@keymail.dev") || !strings.Contains(out.String(), fc.ts.URL) {
+ t.Fatalf("whoami output:\n%s", out.String())
+ }
+
+ // logout revokes console-side and forgets locally.
+ out.Reset()
+ if err := authLogout(&out, []string{"-console", fc.ts.URL}); err != nil {
+ t.Fatalf("authLogout: %v", err)
+ }
+ if !fc.revoked.Load() {
+ t.Fatal("logout did not revoke the token at the console")
+ }
+ f, _ = loadCredentials()
+ if f.find(fc.ts.URL) != nil {
+ t.Fatal("logout left the credential on disk")
+ }
+ if err := authWhoami(&out, []string{"-console", fc.ts.URL}); err == nil {
+ t.Fatal("whoami after logout must fail")
+ }
+}
+
+func TestAuthLoginDenied(t *testing.T) {
+ useTempCredentials(t)
+ fc := newFakeConsole(t)
+ fc.denied.Store(true)
+ var out bytes.Buffer
+ err := authLogin(&out, []string{"-console", fc.ts.URL})
+ if err == nil || !strings.Contains(err.Error(), "denied") {
+ t.Fatalf("denied login = %v, want a refusal", err)
+ }
+ f, _ := loadCredentials()
+ if len(f.Credentials) != 0 {
+ t.Fatal("a denied login stored a credential")
+ }
+}
+
+func TestAuthLoginNeedsAConsole(t *testing.T) {
+ useTempCredentials(t)
+ var out bytes.Buffer
+ if err := authLogin(&out, nil); err == nil || !strings.Contains(err.Error(), "-console") {
+ t.Fatalf("login with no console = %v, want a refusal naming -console", err)
+ }
+}
+
+// A credentials file anyone can read is a credential anyone can steal: the
+// CLI says so rather than pretending it is fine.
+func TestLooseCredentialPermissionsAreReported(t *testing.T) {
+ path := useTempCredentials(t)
+ f, _ := loadCredentials()
+ f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t", ExpiresAt: time.Now().Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(path, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if warn := credentialPermWarning(); warn == "" || !strings.Contains(warn, path) {
+ t.Fatalf("permission warning = %q, want it to name %s", warn, path)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
new file mode 100644
index 000000000..faa76a60c
--- /dev/null
+++ b/internal/console/authcli.go
@@ -0,0 +1,671 @@
+// 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"
+ "fmt"
+ "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.
+ // It is NOT a defence on its own: the device code is chosen by the
+ // caller, so a flood simply presents a fresh key every time. cliPollIPBurst
+ // is the limit that actually bounds a flood.
+ cliPollBurst = 60
+ // cliPollIPBurst bounds polling per client IP, whatever device codes it
+ // claims to hold. Roomy enough for a shared NAT with a dozen developers
+ // mid-login at once.
+ cliPollIPBurst = 240
+ // cliGuessBurst bounds WRONG typed codes, counted against the signed-in
+ // address and against the client IP. 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
+ lastSweep time.Time
+ hits map[string]*rateWindow
+}
+
+type rateWindow struct {
+ start time.Time
+ n int
+}
+
+// maxRateKeys hard-bounds a limiter's map. Some limiters are keyed on
+// caller-chosen values (a device code), so "one entry per distinct key" is a
+// memory budget an attacker writes — this is where that stops.
+const maxRateKeys = 8192
+
+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.
+//
+// The sweep runs at most once per window, NEVER per request: an O(n) walk on
+// every call — while holding this mutex, in front of a database limited to one
+// writer that every console page also queues behind — turns a flood on one
+// endpoint into a stall on the whole console. Over the hard bound the map is
+// dropped wholesale rather than walked, because under a flood of made-up keys
+// there is nothing to distinguish worth keeping, and forgetting counters is a
+// far smaller failure than freezing the site.
+func (l *rateLimiter) allow(key string, now time.Time) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if now.Sub(l.lastSweep) >= l.window {
+ for k, w := range l.hits {
+ if now.Sub(w.start) > l.window {
+ delete(l.hits, k)
+ }
+ }
+ l.lastSweep = now
+ }
+ if len(l.hits) > maxRateKeys {
+ l.hits = make(map[string]*rateWindow, 64)
+ l.lastSweep = now
+ }
+ 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, pollIP, guessAddr, guessIP, write *rateLimiter
+}
+
+func newCLILimits() *cliLimits {
+ return &cliLimits{
+ start: newRateLimiter(cliStartBurst, cliRateWindow),
+ poll: newRateLimiter(cliPollBurst, cliRateWindow),
+ pollIP: newRateLimiter(cliPollIPBurst, cliRateWindow),
+ // Wrong codes are counted against the signed-in ADDRESS and the client
+ // IP, never the session token: a session is re-minted by signing in
+ // again, so a token-keyed counter is one the attacker resets at will.
+ guessAddr: newRateLimiter(cliGuessBurst, cliRateWindow),
+ guessIP: 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.
+//
+// X-Forwarded-For is only consulted when the PEER is one we put there: the
+// edge on this box (loopback — the console listens on 127.0.0.1) or a
+// configured edge address. From anyone else the header is just a string the
+// client typed, and honouring it would let one caller present a fresh "IP"
+// per request and walk straight past every per-IP limit below.
+//
+// When the peer is trusted, the LAST element is the one to take: the edge
+// appends its own view of the peer with SetXForwarded, so everything before
+// it may have been supplied by the client.
+func (s *Server) clientIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ host = r.RemoteAddr
+ }
+ if !s.trustsProxy(host) {
+ return host
+ }
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+ parts := strings.Split(xff, ",")
+ if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" {
+ return ip
+ }
+ }
+ return host
+}
+
+// trustsProxy reports whether a peer is allowed to speak for somebody else.
+func (s *Server) trustsProxy(host string) bool {
+ ip := net.ParseIP(host)
+ if ip == nil {
+ return false
+ }
+ if ip.IsLoopback() {
+ return true // the edge, on this box, which is the only deployment shape
+ }
+ for _, e := range s.cfg.EdgeIPs {
+ if other := net.ParseIP(strings.TrimSpace(e)); other != nil && other.Equal(ip) {
+ return true
+ }
+ }
+ return false
+}
+
+// ---- 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.
+//
+// PRINTABLE ASCII ONLY, which is stricter than it looks like it needs to be.
+// This is the one field on the approval page a human is asked to recognise —
+// "is that my terminal?" — and it is chosen by whoever is asking for the
+// credential. Allowing the full printable Unicode range would allow
+// right-to-left overrides and zero-width joiners, which exist precisely to
+// make a string render as something other than what it is. A label is
+// user@host; nobody needs U+202E in one.
+func cleanLabel(in string) string {
+ var b strings.Builder
+ for _, r := range in {
+ if r < 0x20 || r > 0x7e {
+ 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 := s.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
+ }
+ // Both keys, and the IP one first. The device code is chosen by the
+ // caller, so a per-code limit alone is no limit at all — every made-up
+ // code is a fresh budget, and a fresh map entry. The per-code limit still
+ // earns its place: it stops one honest-but-broken client spinning, without
+ // two developers behind one NAT throttling each other.
+ now := time.Now()
+ if !s.cli.pollIP.allow(s.clientIP(r), now) || !s.cli.poll.allow(hashToken(req.DeviceCode), now) {
+ jsonError(w, http.StatusTooManyRequests, "polling too fast")
+ return
+ }
+ status, token, addr, 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 {
+ out["token"] = token
+ out["address"] = addr
+ out["expires_at"] = time.Now().Add(CLITokenTTL).UTC().Format(time.RFC3339)
+ out["console"] = s.cfg.Origin
+ go s.notifyCLIIssued(addr, token)
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+// notifyCLIIssued tells the human, in their own Keymail inbox, that a terminal
+// now holds their identity — naming it, where it dialled from, and how to kill
+// it.
+//
+// This is the control that makes a phished approval DETECTABLE. Every other
+// defence in this flow is preventive and therefore fallible; a message that
+// arrives whether or not the approval was a good idea is what turns "somebody
+// has been me for two months" into "I revoked that within the hour". Same
+// sealed-envelope channel as sign-in confirmations and invites — the platform
+// still emails nobody.
+//
+// Off the request goroutine on purpose: a slow Keymail must never stall the
+// poll the CLI is waiting on. Issuance has already happened and the request row
+// is burnt, so a CLI that gave up would otherwise leave a token nobody
+// collected. Best-effort throughout — a user who has never completed the OAuth
+// ceremony has no grant to send with, and that is not a reason to fail a login.
+func (s *Server) notifyCLIIssued(address, rawToken string) {
+ defer func() {
+ if s.cliNotified != nil {
+ s.cliNotified <- struct{}{}
+ }
+ }()
+ grant, err := s.store.GrantToken(address)
+ if err != nil || grant == "" {
+ return
+ }
+ label, from := "", ""
+ if toks, err := s.store.CLITokens(address); err == nil {
+ for _, t := range toks {
+ if t.ID == hashToken(rawToken) {
+ label, from = t.Label, t.RemoteAddr
+ }
+ }
+ }
+ if label == "" {
+ label = "(unnamed terminal)"
+ }
+ if from == "" {
+ from = "an address the console could not determine"
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ if _, err := s.km.SendApp(ctx, grant, address,
+ "A command line signed in as you on the CARLOS platform",
+ "A terminal was just approved to act as "+address+" on the CARLOS platform.\n\n"+
+ " terminal: "+label+"\n"+
+ " from: "+from+"\n"+
+ " when: "+time.Now().UTC().Format("2006-01-02 15:04 UTC")+"\n\n"+
+ "It can do anything you can do from the command line, for the next "+
+ fmt.Sprint(int(CLITokenTTL/(24*time.Hour)))+" days.\n\n"+
+ "If that was you running `carlos auth login`, nothing to do.\n\n"+
+ "If it was NOT you — if you clicked a link somebody sent you — revoke it now:\n\n"+
+ s.cfg.Origin+"/auth/cli/tokens\n"); err != nil {
+ log.Printf("console: could not notify %s of a CLI token issue: %v", address, err)
+ }
+}
+
+// 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 // the code the human typed, echoed back into the form
+ // LinkCode is the code carried by the ?code= in the URL they followed.
+ // Shown ONLY as something to check against the terminal's screen — never
+ // prefilled into the form, never resolved. See handleCLIApprovePage.
+ LinkCode 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.
+//
+// IT DELIBERATELY DOES NOT ACT ON ?code=. Not to resolve it, not to look it
+// up, and above all not to render an Approve button for it. A prefilled code
+// that arrives one click from approval is the device-code phishing attack that
+// has been run at scale against Microsoft tenants: the attacker starts the
+// flow, sends the victim the ready-made link with a plausible pretext, and one
+// click binds a 90-day credential — held by the attacker — to the victim's
+// identity. Requiring the code to be TYPED is what breaks it: to type it you
+// have to be reading it off a terminal you started, and a code you cannot see
+// is one you cannot transcribe. The value from the URL is shown only as
+// something to check against your screen, never as something to submit.
+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))}
+ // Echoed for comparison only — no store lookup, which also means this page
+ // is not an oracle for enumerating live codes.
+ p.LinkCode = NormalizeUserCode(r.URL.Query().Get("code"))
+ s.renderCLI(w, http.StatusOK, "cli_approve", p)
+}
+
+// handleCLIApprove is both steps a human takes, distinguished by action:
+//
+// continue — a typed code is looked up and the terminal asking is DESCRIBED
+// (label, where it dialled from, when it expires)
+// approve — the decision, on a code the human has now both typed and seen
+// described
+// deny — "that wasn't me", which is final
+//
+// All three are 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"))
+ action := r.FormValue("action")
+ now := time.Now()
+ p := cliPage{page: page{Title: "Approve a terminal", Address: addr, CSRF: raw},
+ Code: code, Days: int(CLITokenTTL / (24 * time.Hour))}
+
+ // Wrong codes are counted wherever a code is resolved, and against both
+ // the signed-in address and the client IP. The exposure this bounds is not
+ // the obvious one: guessing somebody else's PENDING code and approving it
+ // binds THEIR terminal to YOUR address, which is the inverted-payoff half
+ // of this flow and the reason the counter has to exist even though ~39
+ // bits in ten minutes is not findable.
+ badCode := func() bool {
+ ip := s.clientIP(r)
+ okAddr := s.cli.guessAddr.allow(addr, now)
+ okIP := s.cli.guessIP.allow(ip, now)
+ return okAddr && okIP
+ }
+ tooMany := func() {
+ s.renderMessage(w, r, http.StatusTooManyRequests, "Too many attempts",
+ "Too many bad login codes. Wait a minute, then try again.")
+ }
+
+ switch action {
+ case "approve", "deny":
+ var err error
+ if action == "deny" {
+ err = s.store.DenyCLIAuth(code, now)
+ } else {
+ err = s.store.ApproveCLIAuth(code, addr, now)
+ }
+ if err != nil {
+ if !badCode() {
+ tooMany()
+ 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 action == "deny" {
+ p.Title = "Denied"
+ }
+ s.renderCLI(w, http.StatusOK, "cli_decided", p)
+ default:
+ // "continue": describe the terminal, then let the human decide.
+ req, err := s.store.CLIAuthByUserCode(code, now)
+ if err != nil {
+ if !badCode() {
+ tooMany()
+ return
+ }
+ p.Error = "No login is waiting on that code — check what your terminal printed. " +
+ "Codes last ten minutes."
+ s.renderCLI(w, http.StatusOK, "cli_approve", p)
+ return
+ }
+ p.Request = req
+ s.renderCLI(w, http.StatusOK, "cli_approve", 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 {
+ // Logged, not returned: every other failure in this console answers a
+ // caller with a fixed sentence and keeps the detail on the box. A
+ // mint failure is a bucket error, and bucket errors carry key names,
+ // endpoints and occasionally credentials-adjacent text.
+ log.Printf("console: cli mint account for %s: %v", addr, err)
+ jsonError(w, http.StatusBadGateway, "could not create the account — try again")
+ 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..c79cea0d8
--- /dev/null
+++ b/internal/console/authcli_test.go
@@ -0,0 +1,464 @@
+package console
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+// 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)
+ }
+
+ // Typing the code is what produces a described terminal — following a
+ // link never does (see TestCLIApprovalPageNeverApprovesFromALink).
+ csrf := sessionToken(t, fx, browser)
+ code, _, body := postForm(t, browser, fx.ts.URL+"/auth/cli/approve",
+ url.Values{"code": {userCode}, "action": {"continue"}, "csrf": {csrf}})
+ if code != http.StatusOK {
+ t.Fatalf("continue = %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.
+ 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)
+ }
+}
+
+// The device-code phishing attack, refused. An attacker starts a login, sends
+// the victim the ready-made verification_uri_complete with a pretext, and the
+// victim clicks it while signed in. If that page rendered an Approve button
+// for the attacker's code, one click would bind a 90-day credential — held by
+// the attacker — to the victim's identity. This is the campaign that has been
+// run against Microsoft tenants at scale, so the page must not do it: no
+// lookup, no description, no Approve button, and an EMPTY input the human has
+// to type into off their own screen.
+func TestCLIApprovalPageNeverApprovesFromALink(t *testing.T) {
+ fx := newConsoleFixture(t)
+ victim := newBrowser(t)
+ signUp(t, fx, victim, "code-phish")
+
+ // The attacker's terminal, started from somewhere else entirely.
+ _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "attacker@vps"}, "")
+ userCode := str(t, start, "user_code")
+
+ status, _, body := get(t, victim, fx.ts.URL+"/auth/cli?code="+url.QueryEscape(userCode))
+ if status != http.StatusOK {
+ t.Fatalf("approval page = %d", status)
+ }
+ // The code is echoed to be CHECKED against the terminal's screen…
+ if !strings.Contains(body, userCode) {
+ t.Fatalf("the page hides the code the link carried, so it cannot be checked:\n%s", body)
+ }
+ // …but never described (that would also make this an enumeration oracle)…
+ if strings.Contains(body, "attacker@vps") {
+ t.Fatalf("the page resolved a code from a URL:\n%s", body)
+ }
+ // …and never one click from approval.
+ if strings.Contains(body, `value="approve"`) {
+ t.Fatalf("the page rendered an Approve button for a code from a URL:\n%s", body)
+ }
+ if strings.Contains(body, `name="code" value="`+userCode) {
+ t.Fatalf("the page prefilled the typed-code field from the URL:\n%s", body)
+ }
+ // The request really is still pending — the page did not consume it, it
+ // simply refused to act on it.
+ _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": str(t, start, "device_code")}, "")
+ if str(t, poll, "status") != CLIAuthPending {
+ t.Fatalf("poll = %v, want the request untouched and pending", poll)
+ }
+}
+
+// Issuing a credential tells the human, in the inbox their identity is
+// anchored to. Every other defence here is preventive; this is the one that
+// makes a phished approval detectable afterwards.
+func TestCLIIssuanceNotifiesTheUser(t *testing.T) {
+ fx := newConsoleFixture(t)
+ browser := newBrowser(t)
+ signUp(t, fx, browser, "code-notify")
+ fx.srv.cliNotified = make(chan struct{}, 1)
+
+ _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "paul@laptop"}, "")
+ userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code")
+ postForm(t, browser, fx.ts.URL+"/auth/cli/approve",
+ url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {sessionToken(t, fx, browser)}})
+ if _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, ""); str(t, poll, "token") == "" {
+ t.Fatalf("no token issued: %v", poll)
+ }
+ select {
+ case <-fx.srv.cliNotified:
+ case <-time.After(5 * time.Second):
+ t.Fatal("no notification attempt after a token was issued")
+ }
+ var found string
+ for _, body := range fx.fk.sentBodies() {
+ if strings.Contains(body, "command line") || strings.Contains(body, "terminal") {
+ found = body
+ }
+ }
+ if found == "" {
+ t.Fatalf("no issuance notice in the sealed sends: %v", fx.fk.sentBodies())
+ }
+ for _, want := range []string{"paul@laptop", "/auth/cli/tokens", "revoke"} {
+ if !strings.Contains(found, want) {
+ t.Fatalf("issuance notice missing %q:\n%s", want, found)
+ }
+ }
+}
+
+// X-Forwarded-For is only believed from a peer we put there. Otherwise one
+// caller presents a fresh "IP" per request and walks past every per-IP limit.
+func TestClientIPIgnoresUntrustedForwardedFor(t *testing.T) {
+ s := &Server{cfg: Config{EdgeIPs: []string{"198.51.100.9"}}}
+ r := &http.Request{Header: http.Header{}, RemoteAddr: "203.0.113.5:41234"}
+ r.Header.Set("X-Forwarded-For", "1.2.3.4")
+ if got := s.clientIP(r); got != "203.0.113.5" {
+ t.Fatalf("clientIP from an untrusted peer = %q, want the peer address", got)
+ }
+ // The configured edge, and the loopback edge on this box, are believed —
+ // and it is the LAST element that is theirs to state.
+ for _, peer := range []string{"198.51.100.9:9", "127.0.0.1:9"} {
+ r.RemoteAddr = peer
+ r.Header.Set("X-Forwarded-For", "9.9.9.9, 1.2.3.4")
+ if got := s.clientIP(r); got != "1.2.3.4" {
+ t.Fatalf("clientIP behind %s = %q, want 1.2.3.4", peer, got)
+ }
+ }
+}
+
+// The label is the one field a human is asked to recognise, and the machine
+// asking chooses it. Bidi overrides and zero-width joiners exist to make a
+// string render as something it is not.
+func TestCleanLabelIsPrintableASCII(t *testing.T) {
+ for in, want := range map[string]string{
+ "paul@laptop": "paul@laptop",
+ "paul\u202e@evil": "paul@evil",
+ "paul\u200b@laptop": "paul@laptop",
+ "paul\x07@laptop\n": "paul@laptop",
+ " paul@laptop ": "paul@laptop",
+ "héllo@laptop": "hllo@laptop",
+ strings.Repeat("a", 200): strings.Repeat("a", 64),
+ } {
+ if got := cleanLabel(in); got != want {
+ t.Errorf("cleanLabel(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+// 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 counted — at BOTH places a code is resolved, and against the
+// signed-in address and the client IP rather than the session token (which is
+// re-minted by signing in again, and so resets a counter keyed on it).
+//
+// The payoff being bounded here is the inverted one: guessing somebody else's
+// pending code and approving it binds THEIR terminal to YOUR address.
+func TestCLIApproveRateLimitsWrongCodes(t *testing.T) {
+ for _, action := range []string{"continue", "approve"} {
+ t.Run(action, func(t *testing.T) {
+ fx := newConsoleFixture(t)
+ browser := newBrowser(t)
+ signUp(t, fx, browser, "code-brute-"+action)
+ 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": {action}, "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/authowner_test.go b/internal/console/authowner_test.go
new file mode 100644
index 000000000..fc3a489ed
--- /dev/null
+++ b/internal/console/authowner_test.go
@@ -0,0 +1,133 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// An account minted with an owner in its manifest is materialized WITH that
+// owner as a member. This is the whole point of naming an owner at creation:
+// an account whose owner is written nowhere is an account nobody can open,
+// which is the failure this feature exists to end.
+func TestMaterializeHonoursTheManifestOwner(t *testing.T) {
+ ctx := context.Background()
+ rel := release.NewDirStore(t.TempDir())
+ st := openTestStore(t)
+ now := time.Now()
+
+ id, sqid, err := domains.MintAccount(ctx, rel, "Kass", "paul@keymail.dev", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _, err := rel.Get(ctx, domains.AccountManifestKey(sqid))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m domains.AccountManifest
+ if err := json.Unmarshal(body, &m); err != nil {
+ t.Fatal(err)
+ }
+ if m.Owner != "paul@keymail.dev" {
+ t.Fatalf("manifest owner = %q, want paul@keymail.dev", m.Owner)
+ }
+
+ n, err := MaterializeAccounts(ctx, rel, st)
+ if err != nil || n != 1 {
+ t.Fatalf("MaterializeAccounts = %d %v, want 1", n, err)
+ }
+ role, err := st.Role(id, "paul@keymail.dev")
+ if err != nil || role != RoleOwner {
+ t.Fatalf("role after materializing = %q %v, want owner", role, err)
+ }
+
+ // Re-materializing changes nothing — no membership is re-created, no role
+ // is reset. This runs on every console start.
+ if err := st.AddMember(id, "paul@keymail.dev", RoleMember, now); err == nil {
+ t.Fatal("demoting the last owner should have been refused")
+ }
+ if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 0 {
+ t.Fatalf("second MaterializeAccounts = %d %v, want 0", n, err)
+ }
+}
+
+// A manifest with no owner still materializes — every account minted before
+// this existed has none — and still creates no membership out of thin air.
+func TestMaterializeWithoutAnOwnerCreatesNoMembership(t *testing.T) {
+ ctx := context.Background()
+ rel := release.NewDirStore(t.TempDir())
+ st := openTestStore(t)
+ id, _, err := domains.MintAccount(ctx, rel, "Legacy", "", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := MaterializeAccounts(ctx, rel, st); err != nil {
+ t.Fatal(err)
+ }
+ members, err := st.Members(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(members) != 0 {
+ t.Fatalf("members = %v, want none", members)
+ }
+}
+
+// A manifest owner that the sign-in form would refuse is not a membership
+// anybody could ever claim, so it is skipped rather than written.
+func TestMaterializeRefusesAnUnusableOwner(t *testing.T) {
+ ctx := context.Background()
+ rel := release.NewDirStore(t.TempDir())
+ st := openTestStore(t)
+ // MintAccount validates the owner, so a manifest this broken can only
+ // arrive by hand-editing or corruption — which is exactly the case
+ // materialization has to survive. Write it directly.
+ id, sqid, err := domains.MintAccount(ctx, rel, "Typo", "", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err := json.Marshal(domains.AccountManifest{
+ ID: id, Sqid: sqid, Name: "Typo", Owner: "not-an-address", CreatedAt: time.Now(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, etag, err := rel.Get(ctx, AccountManifestKey(sqid))
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = body
+ if _, err := rel.Put(ctx, AccountManifestKey(sqid), raw, etag); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := MaterializeAccounts(ctx, rel, st); err != nil {
+ t.Fatal(err)
+ }
+ if members, err := st.Members(id); err != nil || len(members) != 0 {
+ t.Fatalf("members = %v %v, want none", members, err)
+ }
+}
+
+// The console's own mint stamps the owner into the bucket too, so a second
+// box materializing the same account knows who it belongs to.
+func TestConsoleMintStampsTheOwner(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-owner")
+ _, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "Kass"}, token)
+ sqid := str(t, out, "sqid")
+ body, _, err := fx.rel.Get(context.Background(), AccountManifestKey(sqid))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m domains.AccountManifest
+ if err := json.Unmarshal(body, &m); err != nil {
+ t.Fatal(err)
+ }
+ if m.Owner != "paul@keymail.dev" {
+ t.Fatalf("manifest owner = %q, want paul@keymail.dev", m.Owner)
+ }
+}
diff --git a/internal/console/authstore.go b/internal/console/authstore.go
new file mode 100644
index 000000000..bef0fc0d3
--- /dev/null
+++ b/internal/console/authstore.go
@@ -0,0 +1,444 @@
+// CLI identity, storage half: the approval requests `carlos auth login`
+// opens, and the bearer tokens the console issues when a human approves one.
+//
+// Shape borrowed from the OAuth 2.1 device authorization grant (RFC 8628),
+// because the CLI's real constraint is the same one that grant exists for:
+// the terminal asking for the credential frequently has no browser on it —
+// it is an SSH session on a box, or a headless CI shell. So the console
+// prints a SHORT code the human carries to any browser they do have, and the
+// CLI polls with a long code it never shows anyone. No local callback
+// listener, no assumption that localhost:port is reachable from the browser.
+//
+// Two secrets, deliberately different:
+//
+// - the user code is short (39 bits) because a human types it. It is stored
+// in the clear — it must be looked up BY value when the human types it —
+// and is only safe because it lives ten minutes, is rate-limited at the
+// door, and grants nothing on its own: approving it hands the token to
+// whoever holds the DEVICE code, not to whoever typed the user code.
+// - the device code is 256 bits and is the CLI's bearer secret for the
+// length of the flow. Only its sha256 is stored, like sessions.
+package console
+
+import (
+ "crypto/rand"
+ "crypto/subtle"
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+ "sync/atomic"
+ "time"
+)
+
+const (
+ // CLIAuthTTL bounds one login attempt — the same ten minutes the browser
+ // OAuth flow gets. It bounds the APPROVED state too: an approval nobody
+ // collected is a CLI that walked away, and it must not mint a token later.
+ CLIAuthTTL = 10 * time.Minute
+
+ // CLITokenTTL is how long an issued CLI token lives: 90 days.
+ //
+ // Longer than the 30-day browser session on purpose. Re-authenticating a
+ // CLI is not a click — it is "open a browser, find the code, approve" —
+ // and a credential that expires monthly trains people to paste the token
+ // into a shell profile or a CI secret and never think about it again,
+ // which is strictly worse than a bounded, revocable, listed token. 90 days
+ // is a quarter: long enough that nobody routes around it, short enough
+ // that a laptop that fell out of a bag stops being an identity by itself.
+ // Every token is revocable the moment it is suspect (see CLITokens).
+ CLITokenTTL = 90 * 24 * time.Hour
+
+ // userCodeLen is the length of the typed code, before the grouping dash.
+ // Eight characters of the 30-symbol alphabet below is ~39 bits
+ // (8·log2(30) = 39.3), which with a ten-minute life and a rate-limited
+ // approval endpoint is far more than guessing survives.
+ userCodeLen = 8
+
+ // userCodeAlphabet has no 0/O, 1/I/L or U (which turns into V in some
+ // hands and is the usual source of accidental profanity in code
+ // generators). What is left is 30 symbols, unambiguous read aloud down a
+ // phone.
+ userCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTVWXYZ"
+)
+
+// Poll statuses. Ours, not RFC 8628's error codes: this is the console's own
+// endpoint and a plain word is what the CLI prints to a human.
+const (
+ CLIAuthPending = "pending"
+ CLIAuthApproved = "approved"
+ CLIAuthDenied = "denied"
+ CLIAuthExpired = "expired"
+ // CLIAuthUnknown covers "never existed" and "already collected" with one
+ // answer, so a device code cannot be used to probe for live flows.
+ CLIAuthUnknown = "unknown"
+)
+
+// CLIAuthRequest is one pending login, as the approval page sees it. It
+// deliberately carries no secret: the page renders the label and where the
+// request came from so a human can recognise their own terminal.
+type CLIAuthRequest struct {
+ UserCode string
+ Label string
+ RemoteAddr string
+ CreatedAt time.Time
+ ExpiresAt time.Time
+}
+
+// CLIToken is one issued credential as its owner sees it on the console. ID
+// is the token's sha256, which is safe to put in a page and in a form: it
+// names the row without being the bearer secret.
+type CLIToken struct {
+ ID string
+ Address string
+ Label string
+ RemoteAddr string
+ CreatedAt time.Time
+ ExpiresAt time.Time
+ LastUsed time.Time
+}
+
+// migrateCLIAuth creates the CLI-identity tables. Additive and idempotent,
+// called from Store.migrate.
+func (s *Store) migrateCLIAuth() error {
+ for _, stmt := range []string{
+ // One in-flight login. code_hash is the sha256 of the device code;
+ // user_code is stored in the clear because the human types it and it
+ // has to be looked up by value.
+ `CREATE TABLE IF NOT EXISTS cli_auth_requests (
+ code_hash TEXT PRIMARY KEY,
+ user_code TEXT NOT NULL UNIQUE,
+ label TEXT NOT NULL DEFAULT '',
+ remote_addr TEXT NOT NULL DEFAULT '',
+ address TEXT NOT NULL DEFAULT '',
+ state TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL
+);`,
+ // The issued credential. Only the hash is stored — a console database
+ // that leaks must not hand out working CLI tokens.
+ `CREATE TABLE IF NOT EXISTS cli_tokens (
+ token_hash TEXT PRIMARY KEY,
+ address TEXT NOT NULL,
+ label TEXT NOT NULL DEFAULT '',
+ remote_addr TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ last_used INTEGER NOT NULL DEFAULT 0
+);`,
+ `CREATE INDEX IF NOT EXISTS cli_tokens_address ON cli_tokens (address);`,
+ // Expiry is swept on the write path, and a sweep without these is a
+ // full table scan on a database with one writer that every console
+ // page also queues behind.
+ `CREATE INDEX IF NOT EXISTS cli_tokens_expires ON cli_tokens (expires_at);`,
+ `CREATE INDEX IF NOT EXISTS cli_auth_requests_expires ON cli_auth_requests (expires_at);`,
+ } {
+ if _, err := s.db.Exec(stmt); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// newUserCode mints the typed code, grouped XXXX-XXXX so it can be read out
+// and typed back without losing your place.
+func newUserCode() (string, error) {
+ b := make([]byte, userCodeLen)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ out := make([]byte, 0, userCodeLen+1)
+ for i, v := range b {
+ if i == userCodeLen/2 {
+ out = append(out, '-')
+ }
+ // Modulo bias over a 30-symbol alphabet is at most 0.4 bits across the
+ // whole code; irrelevant against a rate-limited ten-minute window, and
+ // rejection sampling here would only add a loop to read wrong.
+ out = append(out, userCodeAlphabet[int(v)%len(userCodeAlphabet)])
+ }
+ return string(out), nil
+}
+
+// NormalizeUserCode is what the approval form does to whatever a human typed:
+// upper-case, dashes and spaces dropped, then re-grouped. "wxyz 4823",
+// "wxyz-4823" and "WXYZ4823" are the same code.
+func NormalizeUserCode(in string) string {
+ var b strings.Builder
+ for _, r := range strings.ToUpper(strings.TrimSpace(in)) {
+ if r == '-' || r == ' ' || r == '\t' {
+ continue
+ }
+ b.WriteRune(r)
+ }
+ s := b.String()
+ if len(s) != userCodeLen {
+ return s // wrong length: hand it back and let the lookup miss
+ }
+ return s[:userCodeLen/2] + "-" + s[userCodeLen/2:]
+}
+
+// NewCLIAuth opens a login attempt: a typed user code for the human, a device
+// code for the CLI. label is what the CLI calls itself (user@host) and
+// remoteAddr where it dialled from — both are shown on the approval page so a
+// human can tell their own terminal from somebody else's.
+//
+// Housekeeping on the write path, the same idiom handleContinue uses: expired
+// requests and tokens are dropped here rather than by a sweeper nobody runs.
+func (s *Store) NewCLIAuth(label, remoteAddr string, now time.Time) (userCode, deviceCode string, err error) {
+ s.PurgeCLIAuth(now)
+ deviceCode, err = randomToken()
+ if err != nil {
+ return "", "", err
+ }
+ // One retry is enough: a collision on 39 bits of live codes means the
+ // second draw is astronomically unlikely to collide too.
+ for attempt := 0; attempt < 2; attempt++ {
+ userCode, err = newUserCode()
+ if err != nil {
+ return "", "", err
+ }
+ _, err = s.db.Exec(`INSERT INTO cli_auth_requests
+(code_hash, user_code, label, remote_addr, state, created_at, expires_at)
+VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ hashToken(deviceCode), userCode, label, remoteAddr, CLIAuthPending,
+ now.Unix(), now.Add(CLIAuthTTL).Unix())
+ if err == nil {
+ return userCode, deviceCode, nil
+ }
+ if !strings.Contains(err.Error(), "UNIQUE") {
+ return "", "", err
+ }
+ }
+ return "", "", fmt.Errorf("console: could not mint a free login code")
+}
+
+// lastCLIPurge throttles the housekeeping sweep. Process-global rather than
+// per-Store because a process runs one console; the only cost of a skipped
+// sweep is rows living a little longer, since every read already checks
+// expiry itself. Without the throttle a flood of login starts turns into a
+// delete-per-request against a database with SetMaxOpenConns(1) — which is
+// every console page's queue too.
+var lastCLIPurge atomic.Int64
+
+// cliPurgeEvery bounds how often the sweep runs.
+const cliPurgeEvery = 60 * time.Second
+
+// PurgeCLIAuth drops what has aged out, at most once every cliPurgeEvery.
+// Best-effort: losing a sweep must never fail the login it is housekeeping
+// for. Exported so PurgeExpired — the console's general housekeeping — sweeps
+// these tables too.
+func (s *Store) PurgeCLIAuth(now time.Time) {
+ last := lastCLIPurge.Load()
+ if now.Unix()-last < int64(cliPurgeEvery/time.Second) {
+ return
+ }
+ if !lastCLIPurge.CompareAndSwap(last, now.Unix()) {
+ return // another request is doing it
+ }
+ s.db.Exec(`DELETE FROM cli_auth_requests WHERE expires_at < ?`, now.Unix())
+ s.db.Exec(`DELETE FROM cli_tokens WHERE expires_at < ?`, now.Unix())
+}
+
+// CLIAuthByUserCode resolves a typed code to the request it names, for the
+// approval page. Expired and already-decided requests are "no such code" —
+// the page must never offer to approve something that cannot be approved.
+func (s *Store) CLIAuthByUserCode(userCode string, now time.Time) (*CLIAuthRequest, error) {
+ var r CLIAuthRequest
+ var created, expires int64
+ var state string
+ err := s.db.QueryRow(`SELECT user_code, label, remote_addr, state, created_at, expires_at
+FROM cli_auth_requests WHERE user_code = ?`, NormalizeUserCode(userCode)).
+ Scan(&r.UserCode, &r.Label, &r.RemoteAddr, &state, &created, &expires)
+ if err == sql.ErrNoRows {
+ return nil, errors.New("console: no such login code")
+ }
+ if err != nil {
+ return nil, err
+ }
+ if state != CLIAuthPending || now.Unix() > expires {
+ return nil, errors.New("console: that login code has expired or was already used")
+ }
+ r.CreatedAt = time.Unix(created, 0).UTC()
+ r.ExpiresAt = time.Unix(expires, 0).UTC()
+ return &r, nil
+}
+
+// ApproveCLIAuth records that address approved the request. The token is NOT
+// minted here: it is minted when the CLI collects it (PollCLIAuth), so no
+// bearer credential is ever at rest waiting to be picked up.
+func (s *Store) ApproveCLIAuth(userCode, address string, now time.Time) error {
+ return s.decideCLIAuth(userCode, address, CLIAuthApproved, now)
+}
+
+// DenyCLIAuth is the "that wasn't me" button. A denied request is final:
+// approving it afterwards is refused, so a mis-click cannot be walked back
+// into a credential by whoever is holding the terminal.
+func (s *Store) DenyCLIAuth(userCode string, now time.Time) error {
+ return s.decideCLIAuth(userCode, "", CLIAuthDenied, now)
+}
+
+func (s *Store) decideCLIAuth(userCode, address, state string, now time.Time) error {
+ res, err := s.db.Exec(`UPDATE cli_auth_requests SET state = ?, address = ?
+WHERE user_code = ? AND state = ? AND expires_at >= ?`,
+ state, address, NormalizeUserCode(userCode), CLIAuthPending, now.Unix())
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return errors.New("console: that login code has expired or was already used")
+ }
+ return nil
+}
+
+// PollCLIAuth is what `carlos auth login` calls in a loop. On approval it
+// mints the token, records it, deletes the request, and returns the raw token
+// exactly once — the caller never gets a second copy, so a stolen device code
+// replayed later collects nothing.
+// The approving address comes back with the token so the caller never has to
+// re-read the row to learn it — reading it back through CLITokenAddress would
+// stamp "last used" on a credential at the moment it was issued, and "never
+// used since it was issued" is exactly the thing the tokens page exists to
+// show.
+func (s *Store) PollCLIAuth(deviceCode string, now time.Time) (status, rawToken, address string, err error) {
+ if deviceCode == "" {
+ return CLIAuthUnknown, "", "", nil
+ }
+ tx, err := s.db.Begin()
+ if err != nil {
+ return "", "", "", err
+ }
+ defer tx.Rollback()
+ hash := hashToken(deviceCode)
+ var storedHash, state, label, remote string
+ var expires int64
+ err = tx.QueryRow(`SELECT code_hash, state, address, label, remote_addr, expires_at
+FROM cli_auth_requests WHERE code_hash = ?`, hash).
+ Scan(&storedHash, &state, &address, &label, &remote, &expires)
+ if err == sql.ErrNoRows {
+ return CLIAuthUnknown, "", "", nil
+ }
+ if err != nil {
+ return "", "", "", err
+ }
+ // Belt and braces over the indexed lookup: the comparison that decides
+ // whether this caller holds the secret is constant time.
+ if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 {
+ return CLIAuthUnknown, "", "", nil
+ }
+ if now.Unix() > expires {
+ return CLIAuthExpired, "", "", nil
+ }
+ switch state {
+ case CLIAuthPending:
+ return CLIAuthPending, "", "", nil
+ case CLIAuthDenied:
+ return CLIAuthDenied, "", "", nil
+ case CLIAuthApproved:
+ default:
+ return CLIAuthUnknown, "", "", nil
+ }
+ raw, err := randomToken()
+ if err != nil {
+ return "", "", "", err
+ }
+ if _, err := tx.Exec(`INSERT INTO cli_tokens
+(token_hash, address, label, remote_addr, created_at, expires_at)
+VALUES (?, ?, ?, ?, ?, ?)`,
+ hashToken(raw), address, label, remote, now.Unix(), now.Add(CLITokenTTL).Unix()); err != nil {
+ return "", "", "", err
+ }
+ // Burn the request in the same transaction that mints the token: the two
+ // must never be separable, or a crash could hand out two tokens for one
+ // approval.
+ if _, err := tx.Exec(`DELETE FROM cli_auth_requests WHERE code_hash = ?`, hash); err != nil {
+ return "", "", "", err
+ }
+ if err := tx.Commit(); err != nil {
+ return "", "", "", err
+ }
+ return CLIAuthApproved, raw, address, nil
+}
+
+// CLITokenAddress resolves a bearer token to the address that approved it; ok
+// is false for unknown, revoked and expired tokens. This is the whole
+// authorization decision for every CLI-facing endpoint.
+func (s *Store) CLITokenAddress(rawToken string, now time.Time) (string, bool) {
+ if rawToken == "" {
+ return "", false
+ }
+ hash := hashToken(rawToken)
+ var storedHash, addr string
+ var expires int64
+ err := s.db.QueryRow(`SELECT token_hash, address, expires_at FROM cli_tokens WHERE token_hash = ?`,
+ hash).Scan(&storedHash, &addr, &expires)
+ if err != nil {
+ return "", false
+ }
+ if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 {
+ return "", false
+ }
+ if now.Unix() > expires {
+ return "", false
+ }
+ // Best-effort: "last used" is for the owner's benefit on the tokens page,
+ // and a failed write must not fail the request it is annotating.
+ s.db.Exec(`UPDATE cli_tokens SET last_used = ? WHERE token_hash = ?`, now.Unix(), hash)
+ return addr, true
+}
+
+// CLITokens lists one address's live CLI credentials, newest first — the
+// listing the tokens page renders so an owner can see, and kill, every
+// terminal that speaks as them.
+func (s *Store) CLITokens(address string) ([]CLIToken, error) {
+ rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, created_at, expires_at, last_used
+FROM cli_tokens WHERE address = ? ORDER BY created_at DESC, token_hash`, address)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []CLIToken
+ for rows.Next() {
+ var t CLIToken
+ var created, expires, used int64
+ if err := rows.Scan(&t.ID, &t.Address, &t.Label, &t.RemoteAddr, &created, &expires, &used); err != nil {
+ return nil, err
+ }
+ t.CreatedAt = time.Unix(created, 0).UTC()
+ t.ExpiresAt = time.Unix(expires, 0).UTC()
+ if used != 0 {
+ t.LastUsed = time.Unix(used, 0).UTC()
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+// RevokeCLIToken kills one token by its listing id, scoped to its owner: an
+// id is not a secret, so the address is what makes this safe.
+func (s *Store) RevokeCLIToken(address, id string) error {
+ res, err := s.db.Exec(`DELETE FROM cli_tokens WHERE token_hash = ? AND address = ?`, id, address)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return errors.New("console: no such token")
+ }
+ return nil
+}
+
+// RevokeCLITokenValue kills a token by the token itself — what `carlos auth
+// logout` sends, so signing out of a terminal actually retires the credential
+// rather than just forgetting it locally.
+func (s *Store) RevokeCLITokenValue(rawToken string) error {
+ _, err := s.db.Exec(`DELETE FROM cli_tokens WHERE token_hash = ?`, hashToken(rawToken))
+ return err
+}
diff --git a/internal/console/authstore_test.go b/internal/console/authstore_test.go
new file mode 100644
index 000000000..f448bfcef
--- /dev/null
+++ b/internal/console/authstore_test.go
@@ -0,0 +1,211 @@
+package console
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestCLIAuthApproveThenPollIssuesOneToken(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+
+ userCode, deviceCode, err := s.NewCLIAuth("paul@laptop", "192.0.2.7", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(userCode) != userCodeLen+1 || !strings.Contains(userCode, "-") {
+ t.Fatalf("user code %q: want the grouped XXXX-XXXX shape", userCode)
+ }
+ if strings.ContainsAny(userCode, "01ILO") {
+ t.Fatalf("user code %q contains an ambiguous character", userCode)
+ }
+ if deviceCode == userCode || len(deviceCode) < 40 {
+ t.Fatalf("device code %q must be its own high-entropy secret", deviceCode)
+ }
+
+ req, err := s.CLIAuthByUserCode(userCode, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if req.Label != "paul@laptop" || req.RemoteAddr != "192.0.2.7" {
+ t.Fatalf("request = %+v, want the label and address the CLI arrived with", req)
+ }
+
+ // Before approval the CLI is told to keep waiting and gets nothing.
+ if status, tok, _, err := s.PollCLIAuth(deviceCode, now); err != nil || status != CLIAuthPending || tok != "" {
+ t.Fatalf("poll before approval = %q %q %v, want pending and no token", status, tok, err)
+ }
+
+ if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ status, raw, who, err := s.PollCLIAuth(deviceCode, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status != CLIAuthApproved || raw == "" {
+ t.Fatalf("poll after approval = %q %q, want a token", status, raw)
+ }
+ if who != "paul@keymail.dev" {
+ t.Fatalf("poll returned address %q, want the approver's", who)
+ }
+ // Issuing is not using: a token nobody has spent yet must SAY so on the
+ // tokens page, or "last used" is worthless as a way to spot a stolen one.
+ toks, err := s.CLITokens("paul@keymail.dev")
+ if err != nil || len(toks) != 1 {
+ t.Fatalf("CLITokens = %v %v, want one", toks, err)
+ }
+ if !toks[0].LastUsed.IsZero() {
+ t.Fatalf("a freshly issued token reports last used %v", toks[0].LastUsed)
+ }
+
+ // The token is bound to the address that approved it.
+ addr, ok := s.CLITokenAddress(raw, now)
+ if !ok || addr != "paul@keymail.dev" {
+ t.Fatalf("CLITokenAddress = %q %v, want paul@keymail.dev", addr, ok)
+ }
+
+ // The request is single-use: a replayed device code issues nothing.
+ if status, tok, _, _ := s.PollCLIAuth(deviceCode, now); status != CLIAuthUnknown || tok != "" {
+ t.Fatalf("replayed poll = %q %q, want unknown and no token", status, tok)
+ }
+ if _, err := s.CLIAuthByUserCode(userCode, now); err == nil {
+ t.Fatal("the user code still resolves after the token was collected")
+ }
+}
+
+func TestCLIAuthDenyIsFinal(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ userCode, deviceCode, err := s.NewCLIAuth("paul@laptop", "192.0.2.7", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.DenyCLIAuth(userCode, now); err != nil {
+ t.Fatal(err)
+ }
+ if status, tok, _, _ := s.PollCLIAuth(deviceCode, now); status != CLIAuthDenied || tok != "" {
+ t.Fatalf("poll after deny = %q %q, want denied", status, tok)
+ }
+ // Approving a denied request must not resurrect it.
+ if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", now); err == nil {
+ t.Fatal("a denied request was approvable")
+ }
+}
+
+func TestCLIAuthExpires(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ userCode, deviceCode, err := s.NewCLIAuth("paul@laptop", "192.0.2.7", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ later := now.Add(CLIAuthTTL + time.Second)
+ if _, err := s.CLIAuthByUserCode(userCode, later); err == nil {
+ t.Fatal("an expired user code still resolves for approval")
+ }
+ if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", later); err == nil {
+ t.Fatal("an expired request was approvable")
+ }
+ if status, tok, _, _ := s.PollCLIAuth(deviceCode, later); status != CLIAuthExpired || tok != "" {
+ t.Fatalf("poll after expiry = %q %q, want expired", status, tok)
+ }
+}
+
+// An approval that is never collected must not mint a token that outlives the
+// ten-minute window: the human approved a CLI that was already gone.
+func TestCLIAuthApprovedButUncollectedExpires(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ userCode, deviceCode, err := s.NewCLIAuth("paul@laptop", "192.0.2.7", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if status, tok, _, _ := s.PollCLIAuth(deviceCode, now.Add(CLIAuthTTL+time.Second)); status != CLIAuthExpired || tok != "" {
+ t.Fatalf("late collection = %q %q, want expired with no token", status, tok)
+ }
+}
+
+func TestCLITokenLifecycle(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ mint := func(label string) string {
+ t.Helper()
+ userCode, deviceCode, err := s.NewCLIAuth(label, "192.0.2.7", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ _, raw, _, err := s.PollCLIAuth(deviceCode, now)
+ if err != nil || raw == "" {
+ t.Fatalf("mint %s: %v", label, err)
+ }
+ return raw
+ }
+ laptop := mint("paul@laptop")
+ desktop := mint("paul@desktop")
+
+ toks, err := s.CLITokens("paul@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(toks) != 2 {
+ t.Fatalf("CLITokens = %d entries, want 2", len(toks))
+ }
+ for _, tok := range toks {
+ if tok.ID == "" || tok.Address != "paul@keymail.dev" {
+ t.Fatalf("token row %+v is missing an id or address", tok)
+ }
+ if strings.Contains(laptop, tok.ID) || strings.Contains(desktop, tok.ID) {
+ t.Fatal("the listing id contains the bearer secret")
+ }
+ }
+ // Somebody else's listing is empty, and they cannot revoke what is not theirs.
+ if other, err := s.CLITokens("bob@keymail.dev"); err != nil || len(other) != 0 {
+ t.Fatalf("CLITokens(bob) = %v %v, want empty", other, err)
+ }
+ if err := s.RevokeCLIToken("bob@keymail.dev", toks[0].ID); err == nil {
+ t.Fatal("bob revoked paul's token")
+ }
+ if _, ok := s.CLITokenAddress(laptop, now); !ok {
+ t.Fatal("the laptop token stopped working after a refused revocation")
+ }
+
+ // Revoking by id (the console page) and by value (carlos auth logout).
+ var laptopID string
+ for _, tok := range toks {
+ if tok.Label == "paul@laptop" {
+ laptopID = tok.ID
+ }
+ }
+ if err := s.RevokeCLIToken("paul@keymail.dev", laptopID); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := s.CLITokenAddress(laptop, now); ok {
+ t.Fatal("a revoked token still authenticates")
+ }
+ if err := s.RevokeCLITokenValue(desktop); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := s.CLITokenAddress(desktop, now); ok {
+ t.Fatal("a token revoked by value still authenticates")
+ }
+
+ // Expiry is enforced on use, not just on listing.
+ fresh := mint("paul@ci")
+ if _, ok := s.CLITokenAddress(fresh, now.Add(CLITokenTTL+time.Second)); ok {
+ t.Fatal("an expired token still authenticates")
+ }
+ if _, ok := s.CLITokenAddress("not-a-token", now); ok {
+ t.Fatal("a made-up token authenticates")
+ }
+ if _, ok := s.CLITokenAddress("", now); ok {
+ t.Fatal("an empty token authenticates")
+ }
+}
diff --git a/internal/console/materialize.go b/internal/console/materialize.go
index 69f5df4e1..ce021aadc 100644
--- a/internal/console/materialize.go
+++ b/internal/console/materialize.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"log"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/domains"
@@ -92,6 +93,28 @@ func MaterializeAccounts(ctx context.Context, s release.Store, st *Store) (int,
log.Printf("console: did not materialize %s: account id %d is already taken here", key, m.ID)
continue
}
+ // The manifest's owner, when it names one, becomes the account's first
+ // membership. This is what makes `carlos accounts create -owner <addr>`
+ // (and a logged-in CLI's create) reach the console at all: without it
+ // the account materializes with no members, and an account with no
+ // members is one nobody can open — the exact failure this pass used to
+ // leave behind. Only on the FIRST materialization, and only for an
+ // address the sign-in form would accept: a typo'd owner is a grant
+ // nobody could ever claim, and inventing it would just be a stranded
+ // row with a confusing name on it.
+ if m.Owner != "" {
+ if !ValidAddress(m.Owner) {
+ log.Printf("console: account %s names owner %q, which is not a keymail address — "+
+ "nobody could sign in as it, so no membership was created. Fix it with: "+
+ "console member -add <address> %s -role owner", m.Sqid, m.Owner, m.Sqid)
+ } else if err := st.AddMember(m.ID, strings.ToLower(m.Owner), RoleOwner, created); err != nil {
+ // Loud but not fatal: the account itself materialized, and an
+ // operator can still add the owner box-side. Failing the whole
+ // pass here would hide every other account behind one bad row.
+ log.Printf("console: account %s materialized but its owner %s could not be recorded: %v — "+
+ "add them with: console member -add %s %s -role owner", m.Sqid, m.Owner, err, m.Owner, m.Sqid)
+ }
+ }
log.Printf("console: materialized account %s (%s) from the bucket", m.Sqid, m.Name)
n++
// ONLY here — on the pass that created the row, never on a later one.
diff --git a/internal/console/server.go b/internal/console/server.go
index 712ef50d3..536e0f2cd 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -110,7 +110,12 @@ 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)
+ // cliNotified, when non-nil, is signalled after each CLI-token issuance
+ // notification attempt. Tests only — the send is off the request
+ // goroutine, and a test that asserted on it without this would be racing.
+ cliNotified chan struct{}
}
// NewServer wires a server over an opened Store, loading (or minting) the
@@ -130,6 +135,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 +144,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/store.go b/internal/console/store.go
index e4c3e08ec..8b07369be 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -186,7 +186,12 @@ func (s *Store) migrate() error {
return err
}
}
- return s.migrateAppsKey()
+ if err := s.migrateAppsKey(); err != nil {
+ return err
+ }
+ // CLI identity's own tables (authstore.go), kept out of the list above so
+ // the feature is one file to read and one file to remove.
+ return s.migrateCLIAuth()
}
// migrateAppsKey moves the apps table from PRIMARY KEY(app) — a GLOBAL name
@@ -1128,13 +1133,18 @@ func (s *Store) DeleteSession(rawToken string) error {
return err
}
-// PurgeExpired drops expired sessions and confirmation tokens.
+// PurgeExpired drops expired sessions and confirmation tokens — and, on the
+// same pass, the CLI login requests and tokens that have aged out (throttled
+// inside PurgeCLIAuth, which is also called from the login path).
func (s *Store) PurgeExpired(now time.Time) error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now.Unix()); err != nil {
return err
}
- _, err := s.db.Exec(`DELETE FROM confirmations WHERE expires_at < ?`, now.Unix())
- return err
+ if _, err := s.db.Exec(`DELETE FROM confirmations WHERE expires_at < ?`, now.Unix()); err != nil {
+ return err
+ }
+ s.PurgeCLIAuth(now)
+ return nil
}
// ---- confirmations (returning sign-in links) --------------------------------
diff --git a/internal/console/templates/cli_approve.html b/internal/console/templates/cli_approve.html
new file mode 100644
index 000000000..741918fa3
--- /dev/null
+++ b/internal/console/templates/cli_approve.html
@@ -0,0 +1,59 @@
+{{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}}
+ <span class="small">— this name was supplied by the machine asking. Trust
+ it only if it matches the machine in front of you.</span></p>
+ <p><strong>From</strong> {{emptyOrDash .Request.RemoteAddr}}</p>
+ <p class="small">Expires {{fmtTime .Request.ExpiresAt}}</p>
+</div>
+<p>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>, and a note goes to your
+Keymail inbox the moment it is issued.</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>
+{{if .LinkCode}}
+<div class="banner">
+ <p>The link you followed carries the code <code>{{.LinkCode}}</code>.</p>
+ <p class="small"><strong>Check it against your own screen before typing it.</strong>
+ If you did not just run <code>carlos auth login</code> yourself, or that is not
+ the code your terminal printed, close this page: somebody else is asking to
+ become you, and typing their code is what would let them.</p>
+</div>
+{{end}}
+<form class="signin" method="post" action="/auth/cli/approve">
+ <input type="text" name="code" value="" placeholder="XXXX-XXXX" autocomplete="off" spellcheck="false">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <button type="submit" name="action" value="continue">Continue</button>
+</form>
+<p class="small">Codes look like <code>WXYZ-4823</code> and last ten minutes.
+Nothing here is ever approved by following a link — the code has to be typed,
+which means it has to be on a screen in front of you.</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, and a note is on its way to your Keymail
+inbox.</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..3dfd680e5
--- /dev/null
+++ b/internal/console/templates/cli_tokens.html
@@ -0,0 +1,38 @@
+{{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>From</th><th>Signed in</th><th>Last used</th><th>Expires</th><th></th></tr>
+ {{range .Tokens}}
+ <tr>
+ <td>{{emptyOrDash .Label}}</td>
+ <td>{{emptyOrDash .RemoteAddr}}</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. The
+name and the address are what the machine asking told us — if a row here is
+not a terminal you recognise, revoke it.</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}}
diff --git a/internal/domains/mint.go b/internal/domains/mint.go
index b3109ee02..7d827e023 100644
--- a/internal/domains/mint.go
+++ b/internal/domains/mint.go
@@ -23,9 +23,9 @@ import (
// without opening every object; the two must always agree, and both must equal
// Sqid(id).
type AccountManifest struct {
- ID int64 `json:"id"`
- Sqid string `json:"sqid"`
- Name string `json:"name"`
+ ID int64 `json:"id"`
+ Sqid string `json:"sqid"`
+ Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
// Owner is the keymail address that should hold the first owner membership
// of this account. ADDITIVE and omitempty: every manifest written before
diff --git a/main.go b/main.go
index b5b2e87f6..fd1d403c6 100644
--- a/main.go
+++ b/main.go
@@ -39,6 +39,8 @@ func main() {
runChannels(args)
case "rollback":
runRollback(args)
+ case "auth":
+ RunAuth(args)
case "accounts":
RunAccounts(args)
case "release-keygen":
@@ -71,6 +73,8 @@ commands:
edge run the mesh front door (router)
agent run edge + activator + release adoption on this host
add | remove | instances manage routes
+ auth login | whoami | logout — who this terminal is
+ (headless? don't log in: pass -owner instead)
accounts create | list | migrate — the tenancy primitive
ship | promote | channels publish and promote releases (-account <sqid|name>)
adopt run one release-adoption pass (the hourly timer's verb)