diff --git a/cmd/root.go b/cmd/root.go index a5a7fa0fd..2809dec6c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -178,6 +178,7 @@ func MakeCommands() *cobra.Command { rootCmd.AddCommand(newVersionCommand(rootOptions)) rootCmd.AddCommand(newDiagnosticCommand(rootOptions)) rootCmd.AddCommand(newSetupCommand(rootOptions)) + rootCmd.AddCommand(newSignupCommand(rootOptions)) rootCmd.AddCommand(newInitCommand(rootOptions)) rootCmd.AddCommand(followProjectCommand(rootOptions)) diff --git a/cmd/root_test.go b/cmd/root_test.go index bdcc6f534..dc4209e2e 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -16,7 +16,7 @@ var _ = Describe("Root", func() { Describe("subcommands", func() { It("can create commands", func() { commands := cmd.MakeCommands() - Expect(len(commands.Commands())).To(Equal(29)) + Expect(len(commands.Commands())).To(Equal(30)) }) }) diff --git a/cmd/signup.go b/cmd/signup.go new file mode 100644 index 000000000..b14c4986c --- /dev/null +++ b/cmd/signup.go @@ -0,0 +1,287 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "os/signal" + "strings" + "time" + + "github.com/google/uuid" + "github.com/pkg/browser" + "github.com/spf13/cobra" + + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/CircleCI-Public/circleci-cli/telemetry" +) + +const ( + // App base URL override for enterprise / testing. Falls back to + // defaultAppBaseURL when unset. + appBaseURLEnv = "CIRCLECI_APP_URL" + defaultAppBaseURL = "https://app.circleci.com" + + // handshakeTimeout bounds how long the CLI waits for the browser-side + // authentication to complete before giving up. It's a pure UX knob — + // the auth-svc Redis TTL (sized to cover the POST→next-poll window, + // currently 60s) is an independent server-side concern. The only + // structural requirement is that the server TTL comfortably exceed + // handshakePollWait; the client timeout is decoupled from it. + handshakeTimeout = 10 * time.Minute + handshakePollWait = 3 * time.Second + handshakeHTTPTO = 10 * time.Second + handshakeMaxNetErrs = 3 // consecutive transient errors tolerated +) + +type signupOptions struct { + cfg *settings.Config + noBrowser bool + force bool +} + +func newSignupCommand(config *settings.Config) *cobra.Command { + opts := signupOptions{ + cfg: config, + } + + cmd := &cobra.Command{ + Use: "signup", + Short: "Sign up for a CircleCI account or authenticate an existing account", + RunE: func(cmd *cobra.Command, _ []string) error { + err := runSignup(cmd, opts) + + telemetryClient, ok := telemetry.FromContext(cmd.Context()) + if ok { + _ = telemetryClient.Track(createSignupEvent(opts.noBrowser, err)) + } + + return err + }, + } + + cmd.Flags().BoolVar(&opts.noBrowser, "no-browser", false, "Don't open a browser — print the signup URL so you can visit it from any device") + cmd.Flags().BoolVar(&opts.force, "force", false, "Run signup even if already authenticated") + + return cmd +} + +func createSignupEvent(noBrowser bool, err error) telemetry.Event { + properties := map[string]interface{}{ + "no_browser": noBrowser, + "has_been_executed": true, + } + if err != nil { + properties["error"] = err.Error() + } + return telemetry.Event{ + Object: "cli-signup", + Action: "signup", + Properties: properties, + } +} + +func appBaseURL() string { + if v := os.Getenv(appBaseURLEnv); v != "" { + return v + } + return defaultAppBaseURL +} + +func runSignup(cmd *cobra.Command, opts signupOptions) error { + if !opts.force && opts.cfg.Token != "" { + fmt.Println("You're already authenticated. Your CLI is configured with a personal API token.") + fmt.Println("If you want to reconfigure, run `circleci setup`.") + return nil + } + + ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt) + defer signal.Stop(sigCh) + go func() { + select { + case <-sigCh: + cancel() + case <-ctx.Done(): + } + }() + + handshakeID := uuid.NewString() + baseURL := appBaseURL() + signupURL := fmt.Sprintf("%s/cli-auth?handshake_id=%s", baseURL, handshakeID) + + if opts.noBrowser { + fmt.Printf("To complete signup, open this URL on any device:\n\n %s\n\n", signupURL) + } else { + trackSignupStep(cmd, "browser_opening", nil) + fmt.Println("Opening your browser to sign up for CircleCI...") + fmt.Printf(" %s\n", signupURL) + if err := browser.OpenURL(signupURL); err != nil { + fmt.Printf("Could not open browser automatically: %v\n", err) + fmt.Println("Please visit the URL above from any device.") + } + } + + fmt.Println("Waiting for browser authentication...") + + // Reuse the configured HTTP client so enterprise installs keep their + // custom CA bundle (cfg.TLSCert) and TLS settings. Per-request deadlines + // are applied via context.WithTimeout so we don't mutate the shared client. + client := opts.cfg.HTTPClient + if client == nil { + client = http.DefaultClient + } + + token, err := pollHandshake(ctx, client, baseURL, handshakeID, handshakeTimeout, handshakePollWait, handshakeHTTPTO) + if err != nil { + if ctx.Err() != nil { + trackSignupStep(cmd, "canceled", nil) + fmt.Println("\nAuthentication canceled.") + return nil + } + trackSignupStep(cmd, "failed", nil) + return fmt.Errorf("signup failed: %w", err) + } + + trackSignupStep(cmd, "token_received", nil) + return saveToken(opts.cfg, token) +} + +// pollHandshake polls the server-side handshake endpoint until a token appears +// (200), the context is cancelled, or the overall timeout elapses. The server +// returns 202 for both pending and post-TTL cache-miss cases, so the timeout +// is the sole expiry path. Transient network errors are retried up to +// handshakeMaxNetErrs consecutive times. pollWait and requestTimeout are +// passed in so tests can drive the loop deterministically without touching +// package-level state. +func pollHandshake(ctx context.Context, client *http.Client, baseURL, handshakeID string, timeout, pollWait, requestTimeout time.Duration) (string, error) { + endpoint := fmt.Sprintf("%s/api/v1/cli-handshake/%s", baseURL, handshakeID) + + deadline := time.NewTimer(timeout) + defer deadline.Stop() + + var netErrs int + for { + token, status, err := handshakePoll(ctx, client, endpoint, requestTimeout) + switch { + case err == nil && status == http.StatusOK: + return token, nil + case err == nil && status == http.StatusAccepted: + netErrs = 0 + case err == nil && isTransientStatus(status): + // 429 (rate limit) and 5xx are transient server-side conditions — + // rate limiting is expected once WEBXP-751 lands Gubernator on the + // unauthenticated GET, and 5xx covers backend blips. Count them + // under the same budget as transport errors; a later 202 resets + // the counter. + netErrs++ + if netErrs > handshakeMaxNetErrs { + return "", fmt.Errorf("handshake endpoint returned repeated transient errors (last status %d)", status) + } + case err == nil: + return "", fmt.Errorf("unexpected response from handshake endpoint: %d", status) + case ctx.Err() != nil: + // Parent context was canceled or hit its deadline — surface it so + // the caller can distinguish from transport-level timeouts. + return "", ctx.Err() + default: + netErrs++ + if netErrs > handshakeMaxNetErrs { + return "", fmt.Errorf("repeated network errors while polling for authentication: %w", err) + } + } + + fmt.Print(".") + + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-deadline.C: + return "", fmt.Errorf("timed out waiting for browser authentication (%s) — run `circleci signup` to try again", timeout) + case <-time.After(pollWait): + } + } +} + +// isTransientStatus reports whether a non-200/non-202 response should be +// retried under the network-error budget rather than failing immediately. +func isTransientStatus(status int) bool { + return status == http.StatusTooManyRequests || (status >= 500 && status <= 599) +} + +// handshakePoll performs a single GET against the handshake endpoint. +// On 200 it decodes and returns the token; on any other status it returns the +// status code for the caller to dispatch on. Network / transport errors surface +// via the error return so the caller can decide whether to retry. The +// per-request deadline comes from a derived context so the shared HTTP client +// doesn't need its Timeout field mutated. +func handshakePoll(ctx context.Context, client *http.Client, endpoint string, requestTimeout time.Duration) (string, int, error) { + reqCtx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, endpoint, nil) + if err != nil { + return "", 0, err + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", resp.StatusCode, nil + } + + // Guard against proxies or misrouted requests returning a 200 with a + // non-JSON body (e.g. Cloudflare HTML error pages on the happy-path URL). + // Surface a readable error with a short body snippet so users aren't + // debugging from `invalid character '<'`. + if mt, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")); mt != "" && mt != "application/json" { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + return "", resp.StatusCode, fmt.Errorf("handshake returned non-JSON response (content-type %q): %s", mt, strings.TrimSpace(string(snippet))) + } + + var body struct { + Token string `json:"token"` + CreatedAt string `json:"created_at"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", resp.StatusCode, fmt.Errorf("failed to parse handshake response: %w", err) + } + if body.Token == "" { + return "", resp.StatusCode, errors.New("handshake response contained no token") + } + return body.Token, resp.StatusCode, nil +} + +func saveToken(cfg *settings.Config, token string) error { + cfg.Token = token + if err := cfg.WriteToDisk(); err != nil { + return fmt.Errorf("failed to save token to config: %w", err) + } + fmt.Println("\n✅ Welcome to CircleCI! Your CLI is now authenticated.") + fmt.Println("\nNext steps:") + fmt.Println(" circleci init — set up a project in the current directory") + fmt.Println(" circleci help — see all available commands") + return nil +} + +func trackSignupStep(cmd *cobra.Command, step string, extra map[string]interface{}) { + client, ok := telemetry.FromContext(cmd.Context()) + if !ok { + return + } + invID, _ := telemetry.InvocationIDFromContext(cmd.Context()) + telemetry.TrackWorkflowStep(client, "signup", step, invID, extra) +} diff --git a/cmd/signup_unit_test.go b/cmd/signup_unit_test.go new file mode 100644 index 000000000..960511dae --- /dev/null +++ b/cmd/signup_unit_test.go @@ -0,0 +1,250 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "time" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/CircleCI-Public/circleci-cli/clitest" + "github.com/CircleCI-Public/circleci-cli/settings" +) + +// testPollWait keeps specs snappy without reaching into package-level state. +const testPollWait = 5 * time.Millisecond + +// testRequestTO is generous enough that httptest servers always reply inside +// it, but short enough that the "unreachable server" spec fails fast. +const testRequestTO = 500 * time.Millisecond + +var _ = Describe("Signup", func() { + Describe("already authenticated guard", func() { + It("should print already authenticated when token exists", func() { + opts := signupOptions{ + cfg: &settings.Config{Token: "existing-token"}, + } + + output := clitest.WithCapturedOutput(func() { + err := runSignup(dummyCmd(), opts) + Expect(err).ShouldNot(HaveOccurred()) + }) + + Expect(output).To(ContainSubstring("already authenticated")) + Expect(output).To(ContainSubstring("circleci setup")) + }) + + It("should not guard when --force is set", func() { + opts := signupOptions{ + cfg: &settings.Config{Token: "existing-token"}, + force: true, + } + + Expect(opts.force).To(BeTrue()) + Expect(!opts.force && opts.cfg.Token != "").To(BeFalse()) + }) + + It("should not guard when no token exists", func() { + opts := signupOptions{ + cfg: &settings.Config{Token: ""}, + } + + Expect(!opts.force && opts.cfg.Token != "").To(BeFalse()) + }) + }) + + Describe("appBaseURL", func() { + AfterEach(func() { + os.Unsetenv(appBaseURLEnv) + }) + + It("returns the default when the env var is unset", func() { + os.Unsetenv(appBaseURLEnv) + Expect(appBaseURL()).To(Equal(defaultAppBaseURL)) + }) + + It("honors the CIRCLECI_APP_URL override", func() { + os.Setenv(appBaseURLEnv, "https://enterprise.example.com") + Expect(appBaseURL()).To(Equal("https://enterprise.example.com")) + }) + }) + + Describe("pollHandshake", func() { + It("returns the token once the backend responds with 200", func() { + var calls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + Expect(r.Method).To(Equal(http.MethodGet)) + Expect(r.URL.Path).To(Equal("/api/v1/cli-handshake/abc-123")) + if n < 2 { + w.WriteHeader(http.StatusAccepted) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"token":"pat-xyz","created_at":"2026-04-16T12:00:00Z"}`) + })) + defer server.Close() + + token, err := pollHandshake(context.Background(), http.DefaultClient, server.URL, "abc-123", time.Minute, testPollWait, testRequestTO) + Expect(err).ShouldNot(HaveOccurred()) + Expect(token).To(Equal("pat-xyz")) + Expect(atomic.LoadInt32(&calls)).To(BeNumerically(">=", 2)) + }) + + It("fails on truly unexpected status codes", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) + })) + defer server.Close() + + _, err := pollHandshake(context.Background(), http.DefaultClient, server.URL, "boom", time.Minute, testPollWait, testRequestTO) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unexpected response")) + }) + + It("retries transient 429 / 5xx responses under the network-error budget", func() { + var calls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + switch n { + case 1: + w.WriteHeader(http.StatusTooManyRequests) + case 2: + w.WriteHeader(http.StatusServiceUnavailable) + default: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"token":"pat-xyz"}`) + } + })) + defer server.Close() + + token, err := pollHandshake(context.Background(), http.DefaultClient, server.URL, "id", time.Minute, testPollWait, testRequestTO) + Expect(err).ShouldNot(HaveOccurred()) + Expect(token).To(Equal("pat-xyz")) + Expect(atomic.LoadInt32(&calls)).To(Equal(int32(3))) + }) + + It("gives up after sustained 429 responses exceed the budget", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + })) + defer server.Close() + + _, err := pollHandshake(context.Background(), http.DefaultClient, server.URL, "id", time.Minute, testPollWait, testRequestTO) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("transient")) + Expect(err.Error()).To(ContainSubstring("429")) + }) + + It("surfaces ctx.Err when the context is canceled", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := pollHandshake(ctx, http.DefaultClient, server.URL, "id", time.Minute, testPollWait, testRequestTO) + Expect(err).To(MatchError(context.Canceled)) + }) + + It("times out when the backend never completes the handshake", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + _, err := pollHandshake(context.Background(), http.DefaultClient, server.URL, "id", 20*time.Millisecond, testPollWait, testRequestTO) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("timed out")) + }) + + It("returns an error after repeated network failures", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + addr := server.URL + server.Close() + + _, err := pollHandshake(context.Background(), http.DefaultClient, addr, "id", time.Minute, testPollWait, testRequestTO) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("network")) + }) + + It("surfaces a readable error when a 200 response carries a non-JSON body", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "502 Bad Gateway") + })) + defer server.Close() + + _, err := pollHandshake(context.Background(), http.DefaultClient, server.URL, "id", time.Minute, testPollWait, testRequestTO) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("non-JSON")) + Expect(err.Error()).To(ContainSubstring("text/html")) + Expect(err.Error()).To(ContainSubstring("502 Bad Gateway")) + }) + }) + + Describe("saveToken", func() { + var tempSettings *clitest.TempSettings + + BeforeEach(func() { + tempSettings = clitest.WithTempSettings() + }) + + AfterEach(func() { + tempSettings.Close() + }) + + It("should write the token to the config file", func() { + cfg := &settings.Config{ + FileUsed: tempSettings.Config.Path, + Host: "https://circleci.com", + } + + output := clitest.WithCapturedOutput(func() { + err := saveToken(cfg, "my-new-token") + Expect(err).ShouldNot(HaveOccurred()) + }) + + Expect(output).To(ContainSubstring("Welcome to CircleCI")) + Expect(output).To(ContainSubstring("Next steps")) + Expect(cfg.Token).To(Equal("my-new-token")) + + // Verify it was persisted to disk + file, err := os.Open(tempSettings.Config.Path) + Expect(err).ShouldNot(HaveOccurred()) + defer file.Close() + + content, err := io.ReadAll(file) + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("token: my-new-token")) + }) + }) + + Describe("createSignupEvent", func() { + It("should create event with no_browser property", func() { + event := createSignupEvent(true, nil) + Expect(event.Object).To(Equal("cli-signup")) + Expect(event.Action).To(Equal("signup")) + Expect(event.Properties["no_browser"]).To(BeTrue()) + Expect(event.Properties["has_been_executed"]).To(BeTrue()) + Expect(event.Properties).ToNot(HaveKey("error")) + }) + + It("should include error when present", func() { + event := createSignupEvent(false, fmt.Errorf("something broke")) + Expect(event.Properties["error"]).To(Equal("something broke")) + Expect(event.Properties["no_browser"]).To(BeFalse()) + }) + }) +})