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..71766b6c7 --- /dev/null +++ b/cmd/signup.go @@ -0,0 +1,239 @@ +package cmd + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "net/http" + "net/url" + "time" + + "github.com/pkg/browser" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/CircleCI-Public/circleci-cli/prompt" + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/CircleCI-Public/circleci-cli/telemetry" +) + +type signupOptions struct { + cfg *settings.Config + noBrowser bool +} + +func newSignupCommand(config *settings.Config) *cobra.Command { + opts := signupOptions{ + cfg: config, + } + + cmd := &cobra.Command{ + Use: "signup", + Short: "Sign up for a CircleCI account and authenticate the CLI", + 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 and prompt for a token instead") + + 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 runSignup(cmd *cobra.Command, opts signupOptions) error { + state, err := generateState() + if err != nil { + return errors.Wrap(err, "failed to generate cryptographic state") + } + + if opts.noBrowser { + return signupNoBrowser(opts, state) + } + + return signupWithBrowser(cmd, opts, state) +} + +func generateState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func signupNoBrowser(opts signupOptions, state string) error { + signupURL := "https://app.circleci.com/authentication/login?f=gho&return-to=/settings/user/tokens" + fmt.Printf("Open this URL in your browser to sign up:\n\n %s\n\n", signupURL) + + token, err := prompt.ReadSecretStringFromUser("Paste your CircleCI API token here") + if err != nil { + return errors.Wrap(err, "failed to read token") + } + + if token == "" { + return errors.New("no token provided") + } + + return saveToken(opts.cfg, token) +} + +func signupWithBrowser(cmd *cobra.Command, opts signupOptions, state string) error { + // Start an ephemeral HTTP server on a random available port. + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return errors.Wrap(err, "failed to start local server") + } + port := listener.Addr().(*net.TCPAddr).Port + + tokenCh := make(chan string, 1) + errCh := make(chan error, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/token", corsMiddleware(handleToken(state, tokenCh, errCh))) + + server := &http.Server{ + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + go func() { + if serveErr := server.Serve(listener); serveErr != nil && serveErr != http.ErrServerClosed { + errCh <- serveErr + } + }() + + // Build the signup URL. The return-to is a relative path so it passes + // the existing domain whitelist. The CLI port and state are embedded as + // query params that the successful-signup page will read. + returnTo := fmt.Sprintf("/successful-signup?source=cli&cli_port=%d&cli_state=%s", port, state) + params := url.Values{} + params.Set("f", "gho") + params.Set("return-to", returnTo) + signupURL := "https://app.circleci.com/authentication/login?" + params.Encode() + + trackSignupStep(cmd, "browser_opening", nil) + fmt.Println("Opening your browser to sign up for CircleCI...") + + if err := browser.OpenURL(signupURL); err != nil { + fmt.Printf("⚠️ Could not open browser automatically: %v\n", err) + fmt.Printf(" Please manually visit: %s\n", signupURL) + } + + fmt.Println("Waiting for authentication...") + + // Wait for the token or an error, with a timeout. + select { + case token := <-tokenCh: + _ = server.Shutdown(context.Background()) + trackSignupStep(cmd, "token_received", nil) + return saveToken(opts.cfg, token) + case err := <-errCh: + _ = server.Shutdown(context.Background()) + trackSignupStep(cmd, "failed", nil) + return errors.Wrap(err, "signup failed") + case <-time.After(5 * time.Minute): + _ = server.Shutdown(context.Background()) + trackSignupStep(cmd, "timeout", nil) + return errors.New("timed out waiting for signup to complete. Run `circleci setup` to manually configure your CLI with a personal API token") + } +} + +// corsMiddleware adds CORS headers allowing the CircleCI frontend to make +// cross-origin requests to the CLI's local server. +func corsMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "https://app.circleci.com") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next(w, r) + } +} + +func handleToken(expectedState string, tokenCh chan<- string, errCh chan<- error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + token := query.Get("token") + state := query.Get("cli_state") + callbackErr := query.Get("error") + + // When an error is present, state validation is best-effort: if state + // is provided it must match, but a missing state is tolerated because + // the frontend may not have had access to it when the failure occurred. + if callbackErr != "" { + if state != "" && state != expectedState { + http.Error(w, "State mismatch — possible CSRF. Please try again.", http.StatusBadRequest) + errCh <- errors.New("state mismatch — possible CSRF attempt") + return + } + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, "Something went wrong. Please return to your terminal.") + errCh <- fmt.Errorf("account created but token delivery failed (%s). Run `circleci setup` to manually configure your CLI with a personal API token", callbackErr) + return + } + + if state != expectedState { + http.Error(w, "State mismatch — possible CSRF. Please try again.", http.StatusBadRequest) + errCh <- errors.New("state mismatch — possible CSRF attempt") + return + } + + if token == "" { + http.Error(w, "Missing token.", http.StatusBadRequest) + errCh <- errors.New("callback returned an empty token. Run `circleci setup` to manually configure your CLI with a personal API token") + return + } + + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, "You may close this window and return to your terminal.") + tokenCh <- token + } +} + +func saveToken(cfg *settings.Config, token string) error { + cfg.Token = token + if err := cfg.WriteToDisk(); err != nil { + return errors.Wrap(err, "failed to save token to config") + } + fmt.Println("\n✅ Welcome to CircleCI! Your CLI is now authenticated.") + 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..ad782f987 --- /dev/null +++ b/cmd/signup_unit_test.go @@ -0,0 +1,232 @@ +package cmd + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/CircleCI-Public/circleci-cli/clitest" + "github.com/CircleCI-Public/circleci-cli/settings" +) + +var _ = Describe("Signup", func() { + Describe("generateState", func() { + It("should return a 32-character hex string", func() { + state, err := generateState() + Expect(err).ShouldNot(HaveOccurred()) + Expect(state).To(HaveLen(32)) + Expect(state).To(MatchRegexp(`^[0-9a-f]{32}$`)) + }) + + It("should generate unique values", func() { + a, _ := generateState() + b, _ := generateState() + Expect(a).ToNot(Equal(b)) + }) + }) + + Describe("corsMiddleware", func() { + var dummyHandler http.HandlerFunc + var handlerCalled bool + + BeforeEach(func() { + handlerCalled = false + dummyHandler = func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + } + }) + + It("should set CORS headers on a GET request", func() { + wrapped := corsMiddleware(dummyHandler) + req := httptest.NewRequest("GET", "/token", nil) + rec := httptest.NewRecorder() + + wrapped.ServeHTTP(rec, req) + + Expect(rec.Header().Get("Access-Control-Allow-Origin")).To(Equal("https://app.circleci.com")) + Expect(rec.Header().Get("Access-Control-Allow-Methods")).To(Equal("GET, OPTIONS")) + Expect(rec.Header().Get("Access-Control-Allow-Headers")).To(Equal("Content-Type")) + Expect(handlerCalled).To(BeTrue()) + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + + It("should return 204 on OPTIONS preflight without calling the handler", func() { + wrapped := corsMiddleware(dummyHandler) + req := httptest.NewRequest("OPTIONS", "/token", nil) + rec := httptest.NewRecorder() + + wrapped.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusNoContent)) + Expect(rec.Header().Get("Access-Control-Allow-Origin")).To(Equal("https://app.circleci.com")) + Expect(handlerCalled).To(BeFalse()) + }) + + It("should pin origin to app.circleci.com not wildcard", func() { + wrapped := corsMiddleware(dummyHandler) + req := httptest.NewRequest("GET", "/token", nil) + rec := httptest.NewRecorder() + + wrapped.ServeHTTP(rec, req) + + origin := rec.Header().Get("Access-Control-Allow-Origin") + Expect(origin).To(Equal("https://app.circleci.com")) + Expect(origin).ToNot(Equal("*")) + }) + }) + + Describe("handleToken", func() { + var ( + tokenCh chan string + errCh chan error + state string + ) + + BeforeEach(func() { + tokenCh = make(chan string, 1) + errCh = make(chan error, 1) + state = "abc123" + }) + + It("should accept a valid token and cli_state", func() { + handler := handleToken(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/token?token=mytoken&cli_state=abc123", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(rec.Body.String()).To(ContainSubstring("You may close this window")) + Eventually(tokenCh).Should(Receive(Equal("mytoken"))) + }) + + It("should reject a cli_state mismatch", func() { + handler := handleToken(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/token?token=mytoken&cli_state=wrong", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(rec.Body.String()).To(ContainSubstring("State mismatch")) + Eventually(errCh).Should(Receive()) + }) + + It("should reject a missing token when no error param is present", func() { + handler := handleToken(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/token?cli_state=abc123", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(rec.Body.String()).To(ContainSubstring("Missing token")) + var received error + Eventually(errCh).Should(Receive(&received)) + Expect(received.Error()).To(ContainSubstring("circleci setup")) + }) + + Context("with error param from frontend", func() { + It("should forward the error when cli_state matches", func() { + handler := handleToken(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/token?error=token_creation_failed&cli_state=abc123", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(rec.Body.String()).To(ContainSubstring("Something went wrong")) + var received error + Eventually(errCh).Should(Receive(&received)) + Expect(received.Error()).To(ContainSubstring("token delivery failed")) + Expect(received.Error()).To(ContainSubstring("token_creation_failed")) + Expect(received.Error()).To(ContainSubstring("circleci setup")) + }) + + It("should tolerate a missing cli_state when error is present", func() { + handler := handleToken(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/token?error=no_token", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + var received error + Eventually(errCh).Should(Receive(&received)) + Expect(received.Error()).To(ContainSubstring("no_token")) + }) + + It("should reject error with a mismatched cli_state", func() { + handler := handleToken(state, tokenCh, errCh) + req := httptest.NewRequest("GET", "/token?error=token_creation_failed&cli_state=wrong", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(rec.Body.String()).To(ContainSubstring("State mismatch")) + Eventually(errCh).Should(Receive()) + }) + }) + }) + + 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(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()) + }) + }) +})