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
17 files changed,
+3449
−85
README.md+42 −1accounts.go+110 −76auth.go+665 −0auth_e2e_test.go+199 −0auth_test.go+359 −0internal/console/authcli.go+671 −0internal/console/authcli_test.go+464 −0internal/console/authowner_test.go+133 −0internal/console/authstore.go+444 −0internal/console/authstore_test.go+211 −0internal/console/materialize.go+23 −0internal/console/server.go+10 −1internal/console/store.go+14 −4internal/console/templates/cli_approve.html+59 −0internal/console/templates/cli_tokens.html+38 −0internal/domains/mint.go+3 −3main.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) |