diff --git a/README.md b/README.md index 0686d37..6d603c5 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,10 @@ The arcade opens on your recently played games, with the library (`l`) and marketplace (`m`) one keystroke away. Press `m` to browse — that much is anonymous — and sign in to install. The bundled games are what a signed-out arcade plays; an account is what adds to them, and it is also what publishing -and the library mirror hang off. The arcade has its own sign-in/sign-up -screens, so you never need the website. +and the library mirror hang off. Run `termcade login`; it displays a short +one-time code for `https://app.termca.de/pair`, where the browser handles +account authentication. Termcade never asks for account credentials in the +terminal. The gate is a product decision, not a security boundary: packages are public GitHub release assets, so an account is not what keeps anyone out. What it @@ -111,7 +113,7 @@ mirror to a library on your account, and `termcade sync` brings it back down enough. Sync only adds: a game you installed from a file stays put, because a server having never heard of it is not a reason to delete it. -Signing up claims a **username** — your publishing handle, and the author +Creating an account in the app claims a **username** — your publishing handle, and the author segment of every game you release. `nicodes/pong` and `aviorstudio/tetris` are the same kind of name: the second belongs to an org, which is a studio more than one person can publish under. Being a member is enough to publish; admin @@ -120,7 +122,7 @@ governs the studio itself. The same works from the command line: ```sh -termcade signup # create an account + claim a handle +termcade login # approve this CLI in your browser termcade add aviorstudio/brickough # add straight from the marketplace termcade dev install .tcade # install a local build while developing termcade whoami # who you are, and what you publish as diff --git a/account.go b/account.go index 7bd73e4..bdd3a51 100644 --- a/account.go +++ b/account.go @@ -1,71 +1,40 @@ package main import ( - "bufio" + "context" + "errors" "fmt" + "io" "os" - "strings" - - "golang.org/x/term" + "os/signal" "github.com/aviorstudio/termcade/internal/registry" ) -// promptCredentials collects email (arg or prompt) and a hidden password. -func promptCredentials(args []string, confirm bool) (email, password string, err error) { - reader := bufio.NewReader(os.Stdin) - if len(args) >= 1 { - email = args[0] - } else { - fmt.Print("email: ") - line, err := reader.ReadString('\n') - if err != nil { - return "", "", err - } - email = strings.TrimSpace(line) - } - if email == "" { - return "", "", fmt.Errorf("an email is required") - } - - fmt.Print("password: ") - raw, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() - if err != nil { - return "", "", fmt.Errorf("reading password: %w", err) - } - password = string(raw) - if confirm { - fmt.Print("confirm password: ") - again, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() - if err != nil { - return "", "", fmt.Errorf("reading password: %w", err) - } - if string(again) != password { - return "", "", fmt.Errorf("passwords do not match") - } - } - return email, password, nil +func cmdLogin(args []string) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + return cmdLoginContext(ctx, args, os.Stdout) } -func cmdLogin(args []string) error { - if len(args) > 1 { - return fmt.Errorf("usage: termcade login [email]") - } - email, password, err := promptCredentials(args, false) - if err != nil { - return err +func cmdLoginContext(ctx context.Context, args []string, out io.Writer) error { + if len(args) != 0 { + return fmt.Errorf("usage: termcade login") } client := registry.New(registry.URL(nil), "") - session, err := client.Login(email, password) + session, err := client.DeviceLogin(ctx, "Termcade CLI", func(uri, code string) { + fmt.Fprintf(out, "\nUsing a browser, visit:\n %s\n\nAnd enter this code:\n %s\n\nWaiting for approval (Ctrl+C to cancel)…\n", uri, code) + }) if err != nil { + if context.Cause(ctx) != nil { + return errors.New("login canceled") + } return err } if err := registry.SaveSession(session); err != nil { return err } - fmt.Printf("logged in as %s (%s)\n", session.Email, session.Registry) + fmt.Fprintf(out, "logged in as %s (%s)\n", session.Email, session.Registry) // Signing in on a new machine is the moment your library should arrive. // Nobody should have to know a second command exists to get the games @@ -79,55 +48,6 @@ func cmdLogin(args []string) error { return nil } -// promptUsername collects the handle a new account claims. It is not -// optional: a handle is the author segment of every game published from this -// account, and an account without one cannot publish at all. -func promptUsername(reader *bufio.Reader) (string, error) { - fmt.Print("username (this is your publishing handle, e.g. nicodes): ") - line, err := reader.ReadString('\n') - if err != nil { - return "", err - } - name := strings.TrimSpace(line) - if name == "" { - return "", fmt.Errorf("a username is required") - } - return name, nil -} - -func cmdSignup(args []string) error { - if len(args) > 1 { - return fmt.Errorf("usage: termcade signup [email]") - } - username, err := promptUsername(bufio.NewReader(os.Stdin)) - if err != nil { - return err - } - email, password, err := promptCredentials(args, true) - if err != nil { - return err - } - client := registry.New(registry.URL(nil), "") - session, err := client.Signup(email, password, username) - if err != nil { - return err - } - if err := registry.SaveSession(session); err != nil { - return err - } - // The handle is the useful half of the greeting: it is what a game id - // starts with, so it is what an author needs to know they have. - if session.Username != "" { - fmt.Printf("welcome to termcade, %s — publish as %s/\n", session.Email, session.Username) - } else { - fmt.Printf("welcome to termcade, %s\n", session.Email) - } - if session.Notice != "" { - fmt.Fprintln(os.Stderr, "note:", session.Notice) - } - return nil -} - func cmdLogout() error { if err := registry.ClearSession(); err != nil { return err @@ -137,7 +57,7 @@ func cmdLogout() error { } // cmdKeys manages publish keys: the credential a release workflow holds so -// publishing does not need a password on a machine nobody is sitting at. +// publishing does not need an interactive account login on a CI runner. func cmdKeys(args []string) error { session, err := registry.LoadSession() if err != nil { @@ -369,7 +289,7 @@ func cmdWhoami() error { fmt.Printf("%s (%s)\n", me.Email, session.Registry) if me.Username == "" { - // A real state: an account whose signup lost a race for its handle. + // A real state: an account whose creation lost a race for its handle. fmt.Println("\nno username yet — claim one with `termcade username `") } else { fmt.Printf("\npublish as:\n %-24s you\n", me.Username) @@ -410,7 +330,7 @@ func cmdUsername(args []string) error { } return fmt.Errorf("%s is taken (by %s)", args[0], kind) } - fmt.Printf("%s is available — claim it with `termcade signup`\n", args[0]) + fmt.Printf("%s is available — create an account in the app, then claim it after `termcade login`\n", args[0]) return nil } diff --git a/account_test.go b/account_test.go new file mode 100644 index 0000000..a4fac02 --- /dev/null +++ b/account_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/aviorstudio/termcade/internal/registry" +) + +func TestLoginDisplaysOnlyPairingMaterialAndPersistsIssuedToken(t *testing.T) { + const ( + device = "tcd_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + token = "tcc_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/device/start": + json.NewEncoder(w).Encode(map[string]any{ + "device_code": device, "user_code": "BCDF-GHJK", + "verification_uri": registry.PairingURI, "expires_in": 30, "interval": 1, + }) + case "/v1/device/poll": + json.NewEncoder(w).Encode(map[string]any{ + "status": "approved", "token": token, "credential_id": "credential-id", + "expires_at": time.Now().Add(30 * 24 * time.Hour).Format(time.RFC3339), + }) + case "/v1/me": + json.NewEncoder(w).Encode(map[string]any{"email": "player@example.test", "username": "player", "orgs": []any{}, "handles": []any{}}) + case "/v1/library": + json.NewEncoder(w).Encode([]any{}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + t.Setenv("TERMCADE_REGISTRY", srv.URL) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + var out bytes.Buffer + if err := cmdLoginContext(context.Background(), nil, &out); err != nil { + t.Fatal(err) + } + displayed := out.String() + for _, want := range []string{registry.PairingURI, "BCDF-GHJK", "logged in as player@example.test"} { + if !strings.Contains(displayed, want) { + t.Errorf("login output missing %q: %s", want, displayed) + } + } + if strings.Contains(displayed, "tcd_") || strings.Contains(displayed, "tcc_") { + t.Fatalf("login output exposed credential material: %s", displayed) + } + session, err := registry.LoadSession() + if err != nil || session == nil || session.Token != token { + t.Fatalf("saved session = %#v, %v", session, err) + } +} + +func TestLoginCancellationIsClean(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := cmdLoginContext(ctx, nil, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "login canceled") || !errors.Is(context.Cause(ctx), context.Canceled) { + t.Fatalf("cancel error = %v", err) + } +} diff --git a/activity_test.go b/activity_test.go index 1482310..65c1b76 100644 --- a/activity_test.go +++ b/activity_test.go @@ -61,7 +61,7 @@ func session(t *testing.T, f *fakeRegistry) *scores.Store { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv("TERMCADE_REGISTRY", f.URL) if err := registry.SaveSession(registry.Session{ - Registry: f.URL, Email: "p@t.dev", Token: "good", + Registry: f.URL, Email: "p@t.dev", Token: "tcc_" + strings.Repeat("A", 43), }); err != nil { t.Fatalf("saving a session: %v", err) } diff --git a/auth_absence_test.go b/auth_absence_test.go new file mode 100644 index 0000000..e1d0483 --- /dev/null +++ b/auth_absence_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +func TestTerminalAuthContainsNoLegacySecretHandling(t *testing.T) { + for _, path := range []string{"account.go", "cli.go", "internal/registry/client.go", "internal/shell/market.go"} { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"auth/login", "auth/signup", "ReadPassword", "promptCredentials"} { + if strings.Contains(string(raw), forbidden) { + t.Errorf("%s still contains legacy terminal secret handling %q", path, forbidden) + } + } + } +} diff --git a/cli.go b/cli.go index 7bedc5e..fb47108 100644 --- a/cli.go +++ b/cli.go @@ -39,8 +39,7 @@ usage: free, signed out) termcade account delete delete your account - termcade signup [email] create a marketplace account - termcade login [email] sign in (publishing and your account library) + termcade login sign in securely in your browser termcade logout sign out termcade dev new [dir] start your own game (id is author/slug) @@ -69,8 +68,6 @@ func runCommand(args []string) bool { err = cmdSync() case "login": err = cmdLogin(args[1:]) - case "signup": - err = cmdSignup(args[1:]) case "logout": err = cmdLogout() case "publish": @@ -148,7 +145,7 @@ func cmdAdd(args []string) error { // pack is what a signed-out arcade has to play; local author iteration is // the explicit `dev install` path above this product boundary. if session == nil { - return fmt.Errorf("installing a game requires an account — run `termcade login` (or `termcade signup`)") + return fmt.Errorf("installing a game requires an account — run `termcade login`") } return addFromRegistry(session, id) @@ -339,6 +336,9 @@ func cmdPublish(args []string) error { switch { case token != "": // A key names its own handle, so no session is needed or wanted. + if !registry.IsPublishKey(token) { + return fmt.Errorf("TERMCADE_TOKEN must be a scoped publish key created by `termcade keys new`") + } case session != nil: token = session.Token default: diff --git a/cli_test.go b/cli_test.go index a58febe..6c927f8 100644 --- a/cli_test.go +++ b/cli_test.go @@ -26,7 +26,7 @@ func TestExpiredSessionDoesNotPretendLibraryRemovalSucceeded(t *testing.T) { t.Cleanup(srv.Close) t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - if err := registry.SaveSession(registry.Session{Registry: srv.URL, Token: "expired"}); err != nil { + if err := registry.SaveSession(registry.Session{Registry: srv.URL, Token: "tcc_" + strings.Repeat("A", 43)}); err != nil { t.Fatal(err) } err := syncLibraryRemove("aviorstudio", "tetris") @@ -54,3 +54,12 @@ func TestMarketplaceAddRequestOrderIsStable(t *testing.T) { t.Fatalf("requests = %v, want %v", got, want) } } + +func TestPublishEnvironmentAcceptsOnlyPublishKeys(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("TERMCADE_TOKEN", "tcc_"+strings.Repeat("A", 43)) + err := cmdPublish([]string{"https://github.com/acme/game", "v1.0.0", "game.tcade"}) + if err == nil || !strings.Contains(err.Error(), "scoped publish key") { + t.Fatalf("CLI credential used as publishing-key environment default: %v", err) + } +} diff --git a/go.mod b/go.mod index 881211a..378369b 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( charm.land/lipgloss/v2 v2.0.5 github.com/BurntSushi/toml v1.6.0 github.com/tetratelabs/wazero v1.12.0 - golang.org/x/term v0.45.0 ) require ( diff --git a/go.sum b/go.sum index 7e9b705..c5d44da 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/aviorstudio/termcade/sdk v0.0.1 h1:yUeOCoyOi/RUg8TH/T7X2/ZpoWULP41CLBXQ4M83KHA= -github.com/aviorstudio/termcade/sdk v0.0.1/go.mod h1:sIU1bL36vUsEunUh1mw6Pr9AXMT5UmesaZsT8uSfKhU= github.com/aviorstudio/termcade/sdk v0.0.2 h1:xUECPAM42Ot++8NAXGp7IPrkj0rtuAv1PtSNoxdPfyQ= github.com/aviorstudio/termcade/sdk v0.0.2/go.mod h1:sIU1bL36vUsEunUh1mw6Pr9AXMT5UmesaZsT8uSfKhU= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= @@ -46,5 +44,3 @@ golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= diff --git a/internal/registry/client.go b/internal/registry/client.go index b74f98c..73dd2fd 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -11,6 +11,7 @@ package registry import ( "bytes" + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -40,6 +41,11 @@ const DefaultURL = "https://api.termca.de" // maxPackageSize matches the registry's publish-time ceiling. const maxPackageSize = 64 << 20 +// maxJSONResponse bounds API metadata. Packages use their separate 64 MiB +// streaming limit; catalog/account JSON has no reason to consume unbounded +// memory if a peer is broken or hostile. +const maxJSONResponse = 1 << 20 + // ErrLoginRequired distinguishes "you need an account" from real failures. var ErrLoginRequired = errors.New("login required") @@ -122,7 +128,7 @@ type Session struct { Email string `json:"email"` Token string `json:"token"` // Username is the handle this account publishes under. Empty is a real - // state — an account whose signup lost a handle race still logs in — and + // state — an account whose creation lost a handle race still logs in — and // means publishing is refused until one is claimed. Username string `json:"username,omitempty"` // Notice is a server-side remark about an otherwise usable session. Not @@ -162,6 +168,10 @@ type apiMessage struct { } func (c *Client) do(method, path string, body, out any) error { + return c.doContext(context.Background(), method, path, body, out) +} + +func (c *Client) doContext(ctx context.Context, method, path string, body, out any) error { var reader io.Reader if body != nil { encoded, err := json.Marshal(body) @@ -170,7 +180,7 @@ func (c *Client) do(method, path string, body, out any) error { } reader = bytes.NewReader(encoded) } - req, err := http.NewRequest(method, c.baseURL+path, reader) + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) if err != nil { return err } @@ -178,10 +188,13 @@ func (c *Client) do(method, path string, body, out any) error { req.Header.Set("Content-Type", "application/json") } if c.token != "" { - req.Header.Set("Authorization", c.token) + req.Header.Set("Authorization", c.authorizationValue()) } resp, err := c.http.Do(req) if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } return unreachable(err) } defer resp.Body.Close() @@ -196,17 +209,53 @@ func (c *Client) do(method, path string, body, out any) error { var msg apiMessage raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) if json.Unmarshal(raw, &msg) == nil && msg.Message != "" { - return responseError{status: resp.StatusCode, message: msg.Message} + return responseError{status: resp.StatusCode, message: safeText(msg.Message, 300)} } - if resp.StatusCode >= 500 { - return fmt.Errorf("the marketplace is having trouble (HTTP %d) — try again shortly", resp.StatusCode) + if resp.StatusCode >= 500 || resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooManyRequests { + return responseError{status: resp.StatusCode, message: fmt.Sprintf("the marketplace is having trouble (HTTP %d) — try again shortly", resp.StatusCode)} } return fmt.Errorf("the marketplace refused that request (HTTP %d)", resp.StatusCode) } if out == nil { return nil } - return json.NewDecoder(resp.Body).Decode(out) + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxJSONResponse+1)) + if err != nil || len(raw) > maxJSONResponse { + return errors.New("the marketplace returned malformed data") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + if err := decoder.Decode(out); err != nil { + return errors.New("the marketplace returned malformed data") + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return errors.New("the marketplace returned malformed data") + } + return nil +} + +func (c *Client) authorizationValue() string { + if strings.HasPrefix(c.token, "tcc_") || strings.HasPrefix(c.token, "tck_") { + return "Bearer " + c.token + } + return c.token +} + +// safeText makes server-controlled prose safe to render in a terminal. It +// removes control characters and applies a byte ceiling before the value can +// reach an error, log, or TUI notice. +func safeText(value string, max int) string { + value = strings.ToValidUTF8(value, "") + var b strings.Builder + for _, r := range value { + if r >= 0x20 && r != 0x7f { + b.WriteRune(r) + } + if b.Len() >= max { + break + } + } + return strings.TrimSpace(b.String()) } // CatalogPage is one page of the marketplace and the cursor that continues it. @@ -340,7 +389,7 @@ func (c *Client) Download(author, slug string) (string, error) { return "", err } if c.token != "" { - req.Header.Set("Authorization", c.token) + req.Header.Set("Authorization", c.authorizationValue()) } resp, err := c.http.Do(req) @@ -410,39 +459,6 @@ func (c *Client) Publish(repo, tag, asset string) (Published, error) { return out, c.do(http.MethodPost, "/v1/publish", body, &out) } -type credentials struct { - Email string `json:"email"` - Password string `json:"password"` - // Username is sent on signup and omitted on login, where the account - // already has one. - Username string `json:"username,omitempty"` -} - -func (c *Client) Login(email, password string) (Session, error) { - var out Session - err := c.do(http.MethodPost, "/v1/auth/login", credentials{Email: email, Password: password}, &out) - if errors.Is(err, ErrLoginRequired) { - return Session{}, errors.New("wrong email or password") - } - if err != nil { - return Session{}, err - } - out.Registry = c.baseURL - return out, nil -} - -// Signup creates an account and claims its handle in one call. The handle is -// required: it is the author segment of every game this account publishes. -func (c *Client) Signup(email, password, username string) (Session, error) { - var out Session - body := credentials{Email: email, Password: password, Username: username} - if err := c.do(http.MethodPost, "/v1/auth/signup", body, &out); err != nil { - return Session{}, err - } - out.Registry = c.baseURL - return out, nil -} - func (c *Client) LibraryAdd(author, slug string) error { return c.do(http.MethodPut, "/v1/library/"+author+"/"+slug, nil, nil) } @@ -559,8 +575,12 @@ type Me struct { } func (c *Client) Me() (Me, error) { + return c.MeContext(context.Background()) +} + +func (c *Client) MeContext(ctx context.Context) (Me, error) { var out Me - return out, c.do(http.MethodGet, "/v1/me", nil, &out) + return out, c.doContext(ctx, http.MethodGet, "/v1/me", nil, &out) } // CreateOrg creates a studio and claims its handle, with the caller as its diff --git a/internal/registry/device.go b/internal/registry/device.go new file mode 100644 index 0000000..36fdea0 --- /dev/null +++ b/internal/registry/device.go @@ -0,0 +1,277 @@ +package registry + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + "time" +) + +const ( + // PairingURI is deliberately compiled in. A compromised API must not be + // able to send a player to a lookalike authorization page. + PairingURI = "https://app.termca.de/pair" + clientID = "termcade-cli" + + minDeviceExpiry = 30 * time.Second + maxDeviceExpiry = 15 * time.Minute + minPollInterval = time.Second + maxPollInterval = 30 * time.Second + maxTokenLifetime = 31 * 24 * time.Hour +) + +var ( + userCodeRE = regexp.MustCompile(`^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}$`) + deviceCodeRE = regexp.MustCompile(`^tcd_[A-Za-z0-9_-]{43}$`) + cliTokenRE = regexp.MustCompile(`^tcc_[A-Za-z0-9_-]{43}$`) + publishKeyRE = regexp.MustCompile(`^tck_[A-Za-z0-9_-]{43}$`) +) + +func IsCLIToken(value string) bool { return cliTokenRE.MatchString(value) } +func IsPublishKey(value string) bool { return publishKeyRE.MatchString(value) } + +var ErrDeviceExpired = errors.New("device authorization expired") + +type DeviceRound struct { + DeviceCode string + UserCode string + VerificationURI string + ExpiresIn time.Duration + Interval time.Duration +} + +type deviceStartResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int64 `json:"expires_in"` + Interval int64 `json:"interval"` +} + +type DevicePoll struct { + Status string `json:"status"` + Token string `json:"token"` + CredentialID string `json:"credential_id"` + ExpiresAt string `json:"expires_at"` + Interval int64 `json:"interval"` +} + +func (c *Client) StartDevice(ctx context.Context, deviceName string) (DeviceRound, error) { + deviceName = safeText(deviceName, 200) + if deviceName == "" { + deviceName = "Termcade CLI" + } + var out deviceStartResponse + err := c.doContext(ctx, http.MethodPost, "/v1/device/start", map[string]string{ + "client_id": clientID, "device_name": deviceName, + }, &out) + if err != nil { + return DeviceRound{}, err + } + round := DeviceRound{ + DeviceCode: out.DeviceCode, UserCode: strings.ToUpper(strings.TrimSpace(out.UserCode)), + VerificationURI: out.VerificationURI, + ExpiresIn: time.Duration(out.ExpiresIn) * time.Second, + Interval: time.Duration(out.Interval) * time.Second, + } + if !deviceCodeRE.MatchString(round.DeviceCode) { + return DeviceRound{}, errors.New("the marketplace returned a malformed device code") + } + if !userCodeRE.MatchString(round.UserCode) { + return DeviceRound{}, errors.New("the marketplace returned a malformed pairing code") + } + if round.VerificationURI != PairingURI { + return DeviceRound{}, errors.New("the marketplace returned an untrusted pairing address") + } + if round.ExpiresIn < minDeviceExpiry || round.ExpiresIn > maxDeviceExpiry { + return DeviceRound{}, errors.New("the marketplace returned an unsafe pairing expiry") + } + if round.Interval < minPollInterval || round.Interval > maxPollInterval { + return DeviceRound{}, errors.New("the marketplace returned an unsafe polling interval") + } + return round, nil +} + +func (c *Client) PollDevice(ctx context.Context, deviceCode string) (DevicePoll, error) { + if !deviceCodeRE.MatchString(deviceCode) { + return DevicePoll{}, errors.New("refusing to poll with a malformed device code") + } + var out DevicePoll + if err := c.doContext(ctx, http.MethodPost, "/v1/device/poll", + map[string]string{"device_code": deviceCode}, &out); err != nil { + return DevicePoll{}, err + } + switch out.Status { + case "pending": + interval := time.Duration(out.Interval) * time.Second + if interval < minPollInterval || interval > maxPollInterval { + return DevicePoll{}, errors.New("the marketplace returned an unsafe polling interval") + } + case "expired": + if out.Token != "" { + return DevicePoll{}, errors.New("the marketplace returned a credential in an expired response") + } + case "approved": + if !cliTokenRE.MatchString(out.Token) { + return DevicePoll{}, errors.New("the marketplace returned a malformed CLI credential") + } + if out.CredentialID == "" || safeText(out.CredentialID, 200) != out.CredentialID { + return DevicePoll{}, errors.New("the marketplace returned a malformed credential identifier") + } + expires, err := time.Parse(time.RFC3339, out.ExpiresAt) + if err != nil || !expires.After(time.Now()) || time.Until(expires) > maxTokenLifetime { + return DevicePoll{}, errors.New("the marketplace returned an unsafe CLI credential expiry") + } + default: + return DevicePoll{}, errors.New("the marketplace returned an unknown device status") + } + return out, nil +} + +type devicePolicy struct { + now func() time.Time + sleep func(context.Context, time.Duration) error +} + +var productionDevicePolicy = devicePolicy{now: time.Now, sleep: sleepContext} + +func sleepContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func transient(err error) bool { + if errors.Is(err, ErrUnreachable) { + return true + } + var response responseError + return errors.As(err, &response) && + (response.status == http.StatusRequestTimeout || response.status == http.StatusTooManyRequests || response.status >= 500) +} + +func nextBackoff(current time.Duration) time.Duration { + if current < time.Second { + return time.Second + } + current *= 2 + if current > maxPollInterval { + return maxPollInterval + } + return current +} + +func waitWithin(ctx context.Context, policy devicePolicy, duration time.Duration, deadline time.Time) error { + remaining := deadline.Sub(policy.now()) + if remaining <= 0 { + return ErrDeviceExpired + } + if duration > remaining { + duration = remaining + } + if err := policy.sleep(ctx, duration); err != nil { + return err + } + if !policy.now().Before(deadline) { + return ErrDeviceExpired + } + return nil +} + +func (c *Client) pollRound(ctx context.Context, round DeviceRound, policy devicePolicy) (DevicePoll, error) { + deadline := policy.now().Add(round.ExpiresIn) + interval := round.Interval + backoff := interval + for { + if err := waitWithin(ctx, policy, backoff, deadline); err != nil { + return DevicePoll{}, err + } + pollCtx, cancel := context.WithDeadline(ctx, deadline) + result, err := c.PollDevice(pollCtx, round.DeviceCode) + cancel() + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return DevicePoll{}, ErrDeviceExpired + } + if transient(err) { + backoff = nextBackoff(backoff) + continue + } + return DevicePoll{}, err + } + backoff = interval + switch result.Status { + case "pending": + interval = time.Duration(result.Interval) * time.Second + backoff = interval + case "expired": + return DevicePoll{}, ErrDeviceExpired + case "approved": + return result, nil + } + } +} + +// DeviceLogin runs complete, restartable device-authorization rounds. Only the +// human code and the fixed pairing URI cross the display callback; the device +// code and issued credential remain request-body/in-memory values. +func (c *Client) DeviceLogin(ctx context.Context, deviceName string, display func(uri, code string)) (Session, error) { + return c.deviceLogin(ctx, deviceName, display, productionDevicePolicy) +} + +func (c *Client) deviceLogin(ctx context.Context, deviceName string, display func(string, string), policy devicePolicy) (Session, error) { + startBackoff := time.Second + expiryBackoff := time.Second + for { + round, err := c.StartDevice(ctx, deviceName) + if err != nil { + if !transient(err) { + return Session{}, err + } + if err := policy.sleep(ctx, startBackoff); err != nil { + return Session{}, err + } + startBackoff = nextBackoff(startBackoff) + continue + } + display(PairingURI, round.UserCode) + approved, err := c.pollRound(ctx, round, policy) + if errors.Is(err, ErrDeviceExpired) { + if err := policy.sleep(ctx, expiryBackoff); err != nil { + return Session{}, err + } + expiryBackoff = nextBackoff(expiryBackoff) + continue + } + if err != nil { + return Session{}, err + } + + credentialClient := New(c.baseURL, approved.Token) + credentialExpiry, _ := time.Parse(time.RFC3339, approved.ExpiresAt) + verifyBackoff := time.Second + var me Me + for { + me, err = credentialClient.MeContext(ctx) + if err == nil { + break + } + if !transient(err) { + return Session{}, fmt.Errorf("verifying issued CLI credential: %w", err) + } + if err := waitWithin(ctx, policy, verifyBackoff, credentialExpiry); err != nil { + return Session{}, fmt.Errorf("verifying issued CLI credential: %w", err) + } + verifyBackoff = nextBackoff(verifyBackoff) + } + return Session{Registry: c.baseURL, Email: me.Email, Username: me.Username, Token: approved.Token}, nil + } +} diff --git a/internal/registry/device_test.go b/internal/registry/device_test.go new file mode 100644 index 0000000..0ab7b00 --- /dev/null +++ b/internal/registry/device_test.go @@ -0,0 +1,257 @@ +package registry + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +const ( + testDevice = "tcd_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + testToken = "tcc_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" +) + +func startResponse(overrides map[string]any) map[string]any { + out := map[string]any{ + "device_code": testDevice, "user_code": "BCDF-GHJK", + "verification_uri": PairingURI, "expires_in": 30, "interval": 1, + } + for key, value := range overrides { + out[key] = value + } + return out +} + +func fakePolicy() devicePolicy { + now := time.Now() + return devicePolicy{ + now: func() time.Time { return now }, + sleep: func(ctx context.Context, d time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + now = now.Add(d) + return nil + } + }, + } +} + +func TestDeviceRoundPendingThenApproved(t *testing.T) { + polls := 0 + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.RequestURI()) + switch r.URL.Path { + case "/v1/device/start": + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["client_id"] != clientID || body["device_name"] != "Termcade CLI" { + t.Errorf("start body = %#v", body) + } + json.NewEncoder(w).Encode(startResponse(nil)) + case "/v1/device/poll": + polls++ + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["device_code"] != testDevice { + t.Errorf("poll body omitted device code") + } + if polls == 1 { + json.NewEncoder(w).Encode(DevicePoll{Status: "pending", Interval: 2}) + return + } + json.NewEncoder(w).Encode(DevicePoll{Status: "approved", Token: testToken, + CredentialID: "credential-id", ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Format(time.RFC3339)}) + } + })) + defer srv.Close() + + client := New(srv.URL, "") + round, err := client.StartDevice(context.Background(), "Termcade CLI") + if err != nil { + t.Fatal(err) + } + approved, err := client.pollRound(context.Background(), round, fakePolicy()) + if err != nil || approved.Token != testToken || polls != 2 { + t.Fatalf("approved = %#v, polls=%d, err=%v", approved, polls, err) + } + for _, path := range paths { + if strings.Contains(path, "tcd_") || strings.Contains(path, "tcc_") { + t.Fatalf("credential material appeared in URL %q", path) + } + } +} + +func TestExpiredRoundRestartsWithBackoff(t *testing.T) { + starts := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/device/start": + starts++ + response := startResponse(nil) + response["device_code"] = "tcd_" + strings.Repeat(string(rune('A'+starts-1)), 43) + json.NewEncoder(w).Encode(response) + case "/v1/device/poll": + if starts == 1 { + json.NewEncoder(w).Encode(DevicePoll{Status: "expired"}) + } else { + json.NewEncoder(w).Encode(DevicePoll{Status: "approved", Token: testToken, + CredentialID: "credential-id", ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Format(time.RFC3339)}) + } + case "/v1/me": + if r.Header.Get("Authorization") != "Bearer "+testToken { + t.Errorf("authorization = %q", r.Header.Get("Authorization")) + } + json.NewEncoder(w).Encode(Me{Email: "player@example.test", Username: "player"}) + } + })) + defer srv.Close() + + displays := 0 + session, err := New(srv.URL, "").deviceLogin(context.Background(), "Termcade CLI", func(uri, code string) { + displays++ + if uri != PairingURI || !userCodeRE.MatchString(code) { + t.Errorf("unsafe display %q %q", uri, code) + } + }, fakePolicy()) + if err != nil || starts != 2 || displays != 2 || session.Token != testToken { + t.Fatalf("session=%#v starts=%d displays=%d err=%v", session, starts, displays, err) + } +} + +func TestPollingRetriesTransientFailureUntilApproval(t *testing.T) { + polls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + polls++ + if polls == 1 { + http.Error(w, `{"message":"temporary\u001b[31m failure"}`, http.StatusServiceUnavailable) + return + } + json.NewEncoder(w).Encode(DevicePoll{Status: "approved", Token: testToken, + CredentialID: "credential-id", ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Format(time.RFC3339)}) + })) + defer srv.Close() + + round := DeviceRound{DeviceCode: testDevice, ExpiresIn: 30 * time.Second, Interval: time.Second} + if _, err := New(srv.URL, "").pollRound(context.Background(), round, fakePolicy()); err != nil || polls != 2 { + t.Fatalf("polls=%d err=%v", polls, err) + } +} + +func TestServerErrorTextIsTerminalSafe(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"message": "bad\x1b[2J\nrequest"}) + })) + defer srv.Close() + _, err := New(srv.URL, "").StartDevice(context.Background(), "Termcade CLI") + if err == nil || strings.ContainsAny(err.Error(), "\x1b\n\r") || err.Error() != "bad[2Jrequest" { + t.Fatalf("unsafe server error = %q", err) + } +} + +func TestOversizedDeviceResponseIsRejected(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte(`{"device_code":"` + strings.Repeat("A", maxJSONResponse) + `"}`)) + })) + defer srv.Close() + _, err := New(srv.URL, "").StartDevice(context.Background(), "Termcade CLI") + if err == nil || err.Error() != "the marketplace returned malformed data" { + t.Fatalf("oversized response error = %v", err) + } +} + +func TestHostileDeviceStartFieldsAreRejected(t *testing.T) { + tests := []struct { + name string + field string + value any + want string + }{ + {"code", "user_code", "BCDF-\x1b[2J", "malformed pairing code"}, + {"uri", "verification_uri", "https://evil.example/pair", "untrusted pairing address"}, + {"short expiry", "expires_in", 1, "unsafe pairing expiry"}, + {"long expiry", "expires_in", 901, "unsafe pairing expiry"}, + {"zero interval", "interval", 0, "unsafe polling interval"}, + {"long interval", "interval", 31, "unsafe polling interval"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(startResponse(map[string]any{test.field: test.value})) + })) + defer srv.Close() + _, err := New(srv.URL, "").StartDevice(context.Background(), "Termcade CLI") + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error=%v, want %q", err, test.want) + } + }) + } +} + +func TestPollMalformedExpiredAndThirtyDayCredential(t *testing.T) { + responses := []DevicePoll{ + {Status: "pending", Interval: 31}, + {Status: "expired", Token: testToken}, + {Status: "approved", Token: "tcc_bad", CredentialID: "id", ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Format(time.RFC3339)}, + {Status: "approved", Token: testToken, CredentialID: "id\n", ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Format(time.RFC3339)}, + {Status: "approved", Token: testToken, CredentialID: "id", ExpiresAt: time.Now().Add(32 * 24 * time.Hour).Format(time.RFC3339)}, + {Status: "approved", Token: testToken, CredentialID: "id", ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Format(time.RFC3339)}, + } + index := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(responses[index]) + index++ + })) + defer srv.Close() + client := New(srv.URL, "") + for i := range len(responses) - 1 { + if _, err := client.PollDevice(context.Background(), testDevice); err == nil { + t.Fatalf("hostile poll response %d accepted", i) + } + } + if result, err := client.PollDevice(context.Background(), testDevice); err != nil || result.Token != testToken { + t.Fatalf("30-day credential rejected: %#v %v", result, err) + } +} + +func TestPollingCancellationAndExpiryAreReachable(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + round := DeviceRound{DeviceCode: testDevice, ExpiresIn: 30 * time.Second, Interval: time.Second} + if _, err := New("https://unused.example", "").pollRound(ctx, round, productionDevicePolicy); !errors.Is(err, context.Canceled) { + t.Fatalf("cancel error = %v", err) + } + + policy := fakePolicy() + round.ExpiresIn = time.Second + if _, err := New("https://unused.example", "").pollRound(context.Background(), round, policy); !errors.Is(err, ErrDeviceExpired) { + t.Fatalf("expiry error = %v", err) + } +} + +func TestRevokedCredentialIsNotReturnedAsSession(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/device/start": + json.NewEncoder(w).Encode(startResponse(nil)) + case "/v1/device/poll": + json.NewEncoder(w).Encode(DevicePoll{Status: "approved", Token: testToken, + CredentialID: "id", ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Format(time.RFC3339)}) + case "/v1/me": + w.WriteHeader(http.StatusUnauthorized) + } + })) + defer srv.Close() + _, err := New(srv.URL, "").deviceLogin(context.Background(), "Termcade CLI", func(string, string) {}, fakePolicy()) + if !errors.Is(err, ErrLoginRequired) { + t.Fatalf("revocation error = %v, want ErrLoginRequired", err) + } +} diff --git a/internal/registry/session.go b/internal/registry/session.go index 86d4054..af539e6 100644 --- a/internal/registry/session.go +++ b/internal/registry/session.go @@ -36,12 +36,18 @@ func LoadSession() (*Session, error) { if s.Token == "" { return nil, nil } + if !IsCLIToken(s.Token) { + return nil, fmt.Errorf("the saved login uses an unsupported credential — run `termcade login`") + } return &s, nil } // SaveSession persists a login. The file is user-readable only: the token is // a bearer credential. func SaveSession(s Session) error { + if !IsCLIToken(s.Token) { + return fmt.Errorf("refusing to save a non-CLI credential") + } path, err := sessionPath() if err != nil { return err @@ -53,7 +59,28 @@ func SaveSession(s Session) error { if err != nil { return err } - return os.WriteFile(path, raw, 0o600) + tmp, err := os.CreateTemp(filepath.Dir(path), ".session-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(raw); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) } // ClearSession logs out. A missing file is already logged out. diff --git a/internal/registry/session_test.go b/internal/registry/session_test.go new file mode 100644 index 0000000..b4248b3 --- /dev/null +++ b/internal/registry/session_test.go @@ -0,0 +1,42 @@ +package registry + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSaveSessionIsAtomicAndAlways0600(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + path := filepath.Join(dir, "termcade", "session.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{"token":"old"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := SaveSession(Session{Registry: "https://api.termca.de", Token: testToken}); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("session permissions = %04o, want 0600", got) + } + loaded, err := LoadSession() + if err != nil || loaded == nil || loaded.Token != testToken { + t.Fatalf("loaded = %#v, %v", loaded, err) + } +} + +func TestSaveSessionRefusesOtherCredentialTypes(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + for _, token := range []string{"", "tck_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "Bearer " + testToken} { + if err := SaveSession(Session{Token: token}); err == nil { + t.Fatalf("saved non-CLI credential %q", token[:min(len(token), 4)]) + } + } +} diff --git a/internal/shell/market.go b/internal/shell/market.go index f3af8a1..c4e0e7d 100644 --- a/internal/shell/market.go +++ b/internal/shell/market.go @@ -27,8 +27,6 @@ type Marketplace struct { Remove func(id string) error // Account reports the signed-in email, or ok=false when signed out. Account func() (string, bool) - SignIn func(email, password string) error - SignUp func(username, email, password string) error SignOut func() error // Reload re-discovers installed games after an install/remove. Reload func() []engine.Registration @@ -57,8 +55,6 @@ type marketOpMsg struct { err error } -type authDoneMsg struct{ err error } - // syncedMsg carries whatever a sync wants said, which is usually nothing, and // what the marketplace currently publishes. type syncedMsg struct { @@ -74,34 +70,7 @@ type marketState struct { busy bool } -const ( - authStageChoose = iota // sign in vs create account - authStageForm -) - -type authState struct { - stage int - chooseIdx int - signup bool - // focus indexes authFields, which is one longer when signing up: a new - // account claims a handle, and an existing one already has it. - focus int - username string - email string - password string - err string - busy bool -} - -// authFields is the form, in tab order. Signing up asks for a handle first — -// it is the name games are published under, so it is the decision being made, -// not an afterthought below the password. -func (a *authState) fields() []*string { - if a.signup { - return []*string{&a.username, &a.email, &a.password} - } - return []*string{&a.email, &a.password} -} +type authState struct{} func (m Model) loadMarket() tea.Cmd { mp := m.mp @@ -139,16 +108,6 @@ func (m Model) syncCmd() tea.Cmd { } } -func (m Model) authCmd(signup bool, username, email, password string) tea.Cmd { - mp := m.mp - return func() tea.Msg { - if signup { - return authDoneMsg{err: mp.SignUp(username, email, password)} - } - return authDoneMsg{err: mp.SignIn(email, password)} - } -} - // latestVersions is what the marketplace currently publishes, by game id. // Games with no release are left out — there is no version to fall behind. func latestVersions(games []MarketGame) map[string]string { @@ -212,17 +171,6 @@ func (m Model) updateMarketMsg(msg tea.Msg) (Model, tea.Cmd, bool) { m.market.notice = msg.verb + " " + msg.id m.notice = msg.verb + " " + msg.id return m, nil, true - case authDoneMsg: - m.auth.busy = false - if msg.err != nil { - m.auth.err = msg.err.Error() - return m, nil, true - } - m.screen = screenMarket - m.market.notice = "signed in" - // The moment an account exists is the moment everything played - // without one has somewhere to go. - return m, m.syncCmd(), true case syncedMsg: if len(msg.latest) > 0 { m.latest = msg.latest @@ -313,76 +261,15 @@ func (m Model) updateMarketKey(key string) (tea.Model, tea.Cmd) { } func (m Model) updateAuthKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - if m.auth.busy { - return m, nil - } key := msg.String() - - if m.auth.stage == authStageChoose { - switch key { - case "esc": - m.screen = screenMarket - return m, nil - case "up", "k", "down", "j": - m.auth.chooseIdx = 1 - m.auth.chooseIdx - case "enter": - m.auth.signup = m.auth.chooseIdx == 1 - m.auth.stage = authStageForm - m.auth.focus = 0 - m.auth.err = "" - } - return m, nil - } - switch key { - case "esc": - m.auth.stage = authStageChoose - m.auth.err = "" - return m, nil - case "tab", "down": - n := len(m.auth.fields()) - m.auth.focus = (m.auth.focus + 1) % n - return m, nil - case "shift+tab", "up": - n := len(m.auth.fields()) - m.auth.focus = (m.auth.focus + n - 1) % n - return m, nil - case "enter": - if m.auth.email == "" || m.auth.password == "" { - m.auth.err = "email and password are required" - return m, nil - } - if m.auth.signup && m.auth.username == "" { - m.auth.err = "a username is required — it is what your games are published under" - return m, nil - } - m.auth.busy = true - m.auth.err = "" - return m, m.authCmd(m.auth.signup, m.auth.username, m.auth.email, m.auth.password) - case "backspace": - field := m.authField() - if *field != "" { - *field = (*field)[:len(*field)-1] - } + case "esc", "q", "enter": + m.screen = screenMarket return m, nil } - - // Printable input lands in the focused field. Key.Text is empty for - // non-text keys, which filters modifiers and function keys for free. - if text := msg.Text; text != "" && !strings.ContainsAny(text, "\n\r\t") { - *m.authField() += text - } return m, nil } -func (m *Model) authField() *string { - fields := m.auth.fields() - if m.auth.focus < 0 || m.auth.focus >= len(fields) { - return &m.auth.email - } - return fields[m.auth.focus] -} - // --------------------------------------------------------------- rendering -- var ( @@ -443,61 +330,10 @@ func (m Model) viewMarket() string { func (m Model) viewAuth() string { var b strings.Builder - - if m.auth.stage == authStageChoose { - b.WriteString(titleStyle.Render("ACCOUNT")) - b.WriteString("\n\n") - for i, label := range []string{"sign in", "create account"} { - if i == m.auth.chooseIdx { - b.WriteString(overlaySel.Render("▸ " + label)) - } else { - b.WriteString(overlayDim.Render(" " + label)) - } - b.WriteByte('\n') - } - b.WriteString("\n" + dimStyle.Render("↑/↓ select · enter continue · esc back")) - return b.String() - } - - title := "SIGN IN" - if m.auth.signup { - title = "CREATE ACCOUNT" - } - b.WriteString(titleStyle.Render(title)) - b.WriteString("\n\n") - - fields := []struct { - label, value string - mask bool - }{ - {"email ", m.auth.email, false}, - {"password", m.auth.password, true}, - } - if m.auth.signup { - fields = append([]struct { - label, value string - mask bool - }{{"username", m.auth.username, false}}, fields...) - } - for i, f := range fields { - value := f.value - if f.mask { - value = strings.Repeat("•", len(value)) - } - style := inputStyle - cursor := " " - if i == m.auth.focus { - style = inputFocused - cursor = "▏" - } - b.WriteString(dimStyle.Render(f.label+" ") + style.Render(value+cursor) + "\n") - } - - if m.auth.busy { - b.WriteString("\n" + dimStyle.Render("talking to the marketplace…")) - } else if m.auth.err != "" { - b.WriteString("\n" + marketNotice.Render(sanitize(m.auth.err))) - } - b.WriteString("\n\n" + dimStyle.Render("tab next field · enter submit · esc back")) + b.WriteString(titleStyle.Render("SECURE SIGN IN")) + b.WriteString("\n\nTermcade never asks for account credentials in the terminal.\n") + b.WriteString("Exit the arcade and run " + inputFocused.Render("termcade login") + ".\n") + b.WriteString("It will show a one-time code for " + inputStyle.Render("https://app.termca.de/pair") + ".") + b.WriteString("\n\n" + dimStyle.Render("enter/esc back")) return b.String() } diff --git a/internal/shell/shell_test.go b/internal/shell/shell_test.go index 8fccc16..337ada6 100644 --- a/internal/shell/shell_test.go +++ b/internal/shell/shell_test.go @@ -363,12 +363,6 @@ func fakeMarket(signedIn *bool, installed *[]engine.Registration) *Marketplace { } return "", false }, - SignIn: func(email, password string) error { *signedIn = true; return nil }, - SignUp: func(username, email, password string) error { - *signedIn = true - signedUpAs = username - return nil - }, SignOut: func() error { *signedIn = false; return nil }, Reload: func() []engine.Registration { g := &fakeGame{} @@ -381,10 +375,6 @@ func fakeMarket(signedIn *bool, installed *[]engine.Registration) *Marketplace { } } -// signedUpAs records the handle the signup form submitted, so a test can -// prove the field is wired rather than merely present. -var signedUpAs string - func newMarketShell(t *testing.T) (Model, *bool, *[]engine.Registration) { t.Helper() t.Setenv("XDG_CONFIG_HOME", t.TempDir()) @@ -393,7 +383,6 @@ func newMarketShell(t *testing.T) (Model, *bool, *[]engine.Registration) { t.Fatal(err) } signedIn := false - signedUpAs = "" var installed []engine.Registration mp := fakeMarket(&signedIn, &installed) m := New(mp.Reload(), st, sdk.Quadrant, mp) @@ -509,44 +498,23 @@ func TestMarketInstallAsGuest(t *testing.T) { } } -func TestMarketSignup(t *testing.T) { - m, signedIn, _ := newMarketShell(t) +func TestMarketAuthCollectsNoCredentials(t *testing.T) { + m, _, _ := newMarketShell(t) mm, cmd := step(t, m, key("m")) mm = drain(t, mm, cmd) mm, _ = step(t, mm, key("l")) - mm, _ = step(t, mm, key("j")) - mm, _ = step(t, mm, key("enter")) - - // Signing up asks for a handle first: it is what games are published - // under, so it is the decision being made rather than a field below the - // password. - typeIn := func(m Model, text string) Model { - for _, r := range text { - m, _ = step(t, m, key(string(r))) + out := view(mm) + for _, want := range []string{"termcade login", "https://app.termca.de/pair", "never asks for account credentials"} { + if !strings.Contains(out, want) { + t.Errorf("secure auth view missing %q:\n%s", want, out) } - return m - } - mm = typeIn(mm, "nicodes") - mm, _ = step(t, mm, key("tab")) - mm = typeIn(mm, "p@t.dev") - mm, _ = step(t, mm, key("tab")) - mm = typeIn(mm, "password123") - - mm, cmd = step(t, mm, key("enter")) - if cmd == nil { - t.Fatal("no signup command issued") - } - mm = drain(t, mm, cmd) - - if signedUpAs != "nicodes" { - t.Errorf("signup submitted username %q, want nicodes", signedUpAs) } - if !*signedIn { - t.Fatal("signup hook not called") + for _, r := range "not-a-secret" { + mm, _ = step(t, mm, key(string(r))) } - if mm.screen != screenMarket { - t.Fatalf("screen = %v after signup", mm.screen) + if strings.Contains(view(mm), "not-a-secret") { + t.Fatal("auth view accepted terminal credential input") } } @@ -800,28 +768,6 @@ func TestFinishingARunQueuesItAndSyncs(t *testing.T) { } } -// Signing in is the moment everything played without an account has somewhere -// to go. -func TestSigningInSyncs(t *testing.T) { - calls := 0 - m := newSyncShell(t, syncingMarket(&calls, "", nil)) - - m.screen = screenAuth - m, cmd := step(t, m, authDoneMsg{}) - if m.screen != screenMarket { - t.Fatalf("screen = %v after signing in", m.screen) - } - if cmd == nil { - t.Fatal("signing in issued no sync") - } - // Batched with the page-clear the route change adds, so it has to be run - // as a chain rather than called. - drain(t, m, cmd) - if calls != 1 { - t.Errorf("sync ran %d times after sign-in, want 1", calls) - } -} - func TestSyncNoticeReachesTheScreen(t *testing.T) { calls := 0 m := newSyncShell(t, syncingMarket(&calls, "", nil)) diff --git a/market.go b/market.go index c51d01d..debbd68 100644 --- a/market.go +++ b/market.go @@ -102,22 +102,6 @@ func newMarketplace(rt *plugin.Runtime, st *scores.Store) *shell.Marketplace { return session.Email, true }, - SignIn: func(email, password string) error { - session, err := registry.New(registry.URL(nil), "").Login(email, password) - if err != nil { - return err - } - return registry.SaveSession(session) - }, - - SignUp: func(username, email, password string) error { - session, err := registry.New(registry.URL(nil), "").Signup(email, password, username) - if err != nil { - return err - } - return registry.SaveSession(session) - }, - SignOut: registry.ClearSession, Reload: reload,