Skip to content

Commit 6db9f76

Browse files
[WEBXP-469] Add circleci signup with Magic Path v2 architecture
Opens browser directly to /cli-auth with CLI params as top-level query params. The frontend handles session detection: authenticated users get instant PAT creation; unauthenticated users are redirected to signup first. Bypasses auth-svc return-to bug entirely. Security hardening: - Strict Origin validation (reject missing/wrong with 403) - Constant-time state comparison via crypto/subtle - CORS pinned to https://app.circleci.com (static, never reflected) - Access-Control-Allow-Private-Network for Chrome PNA - Method validation (GET only on /token) - 127.0.0.1 binding only Features: - Unique PAT label (hostname + timestamp) prevents 422 duplicates - Already-authenticated guard with --force bypass - --no-browser fallback for headless/SSH - JSON responses for structured error handling - Human-readable URL display in terminal - 24 unit tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 49672ea commit 6db9f76

4 files changed

Lines changed: 620 additions & 1 deletion

File tree

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ func MakeCommands() *cobra.Command {
178178
rootCmd.AddCommand(newVersionCommand(rootOptions))
179179
rootCmd.AddCommand(newDiagnosticCommand(rootOptions))
180180
rootCmd.AddCommand(newSetupCommand(rootOptions))
181+
rootCmd.AddCommand(newSignupCommand(rootOptions))
181182
rootCmd.AddCommand(newInitCommand(rootOptions))
182183

183184
rootCmd.AddCommand(followProjectCommand(rootOptions))

cmd/root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ var _ = Describe("Root", func() {
1616
Describe("subcommands", func() {
1717
It("can create commands", func() {
1818
commands := cmd.MakeCommands()
19-
Expect(len(commands.Commands())).To(Equal(29))
19+
Expect(len(commands.Commands())).To(Equal(30))
2020
})
2121
})
2222

cmd/signup.go

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"crypto/subtle"
7+
"encoding/hex"
8+
"encoding/json"
9+
"fmt"
10+
"net"
11+
"net/http"
12+
"net/url"
13+
"os"
14+
"time"
15+
16+
"github.com/pkg/browser"
17+
"github.com/pkg/errors"
18+
"github.com/spf13/cobra"
19+
20+
"github.com/CircleCI-Public/circleci-cli/prompt"
21+
"github.com/CircleCI-Public/circleci-cli/settings"
22+
"github.com/CircleCI-Public/circleci-cli/telemetry"
23+
)
24+
25+
type signupOptions struct {
26+
cfg *settings.Config
27+
noBrowser bool
28+
force bool
29+
}
30+
31+
func newSignupCommand(config *settings.Config) *cobra.Command {
32+
opts := signupOptions{
33+
cfg: config,
34+
}
35+
36+
cmd := &cobra.Command{
37+
Use: "signup",
38+
Short: "Sign up for a CircleCI account or authenticate an existing account",
39+
RunE: func(cmd *cobra.Command, _ []string) error {
40+
err := runSignup(cmd, opts)
41+
42+
telemetryClient, ok := telemetry.FromContext(cmd.Context())
43+
if ok {
44+
_ = telemetryClient.Track(createSignupEvent(opts.noBrowser, err))
45+
}
46+
47+
return err
48+
},
49+
}
50+
51+
cmd.Flags().BoolVar(&opts.noBrowser, "no-browser", false, "Don't open a browser; print the signup URL and prompt for a token instead")
52+
cmd.Flags().BoolVar(&opts.force, "force", false, "Run signup even if already authenticated")
53+
54+
return cmd
55+
}
56+
57+
func createSignupEvent(noBrowser bool, err error) telemetry.Event {
58+
properties := map[string]interface{}{
59+
"no_browser": noBrowser,
60+
"has_been_executed": true,
61+
}
62+
if err != nil {
63+
properties["error"] = err.Error()
64+
}
65+
return telemetry.Event{
66+
Object: "cli-signup",
67+
Action: "signup",
68+
Properties: properties,
69+
}
70+
}
71+
72+
func runSignup(cmd *cobra.Command, opts signupOptions) error {
73+
if !opts.force && opts.cfg.Token != "" {
74+
fmt.Println("You're already authenticated. Your CLI is configured with a personal API token.")
75+
fmt.Println("If you want to reconfigure, run `circleci setup`.")
76+
return nil
77+
}
78+
79+
state, err := generateState()
80+
if err != nil {
81+
return errors.Wrap(err, "failed to generate cryptographic state")
82+
}
83+
84+
if opts.noBrowser {
85+
return signupNoBrowser(opts, state)
86+
}
87+
88+
return signupWithBrowser(cmd, opts, state)
89+
}
90+
91+
func generateState() (string, error) {
92+
b := make([]byte, 16)
93+
if _, err := rand.Read(b); err != nil {
94+
return "", err
95+
}
96+
return hex.EncodeToString(b), nil
97+
}
98+
99+
func signupNoBrowser(opts signupOptions, state string) error {
100+
signupURL := "https://app.circleci.com/authentication/login?f=gho&return-to=/settings/user/tokens"
101+
fmt.Printf("Open this URL in your browser to sign up:\n\n %s\n\n", signupURL)
102+
103+
token, err := prompt.ReadSecretStringFromUser("Paste your CircleCI API token here")
104+
if err != nil {
105+
return errors.Wrap(err, "failed to read token")
106+
}
107+
108+
if token == "" {
109+
return errors.New("no token provided")
110+
}
111+
112+
return saveToken(opts.cfg, token)
113+
}
114+
115+
func signupWithBrowser(cmd *cobra.Command, opts signupOptions, state string) error {
116+
// Start an ephemeral HTTP server on a random available port.
117+
listener, err := net.Listen("tcp", "127.0.0.1:0")
118+
if err != nil {
119+
return errors.Wrap(err, "failed to start local server")
120+
}
121+
port := listener.Addr().(*net.TCPAddr).Port
122+
123+
tokenCh := make(chan string, 1)
124+
errCh := make(chan error, 1)
125+
126+
mux := http.NewServeMux()
127+
mux.HandleFunc("/token", corsMiddleware(handleToken(state, tokenCh, errCh)))
128+
129+
server := &http.Server{
130+
Handler: mux,
131+
ReadTimeout: 10 * time.Second,
132+
WriteTimeout: 10 * time.Second,
133+
}
134+
135+
go func() {
136+
if serveErr := server.Serve(listener); serveErr != nil && serveErr != http.ErrServerClosed {
137+
errCh <- serveErr
138+
}
139+
}()
140+
141+
// Generate a unique PAT label to avoid 422 duplicate errors when the
142+
// user runs signup multiple times or from different machines.
143+
hostname, err := os.Hostname()
144+
if err != nil {
145+
hostname = "unknown"
146+
}
147+
label := fmt.Sprintf("circleci-cli-%s-%d", hostname, time.Now().Unix())
148+
149+
// Build the signup URL. Go directly to /cli-auth (Magic Path).
150+
// The frontend checks for an existing session: if authenticated, it creates
151+
// the PAT immediately; if not, it redirects to signup first.
152+
params := url.Values{}
153+
params.Set("cli_port", fmt.Sprintf("%d", port))
154+
params.Set("cli_state", state)
155+
params.Set("cli_label", label)
156+
signupURL := "https://app.circleci.com/cli-auth?" + params.Encode()
157+
158+
trackSignupStep(cmd, "browser_opening", nil)
159+
fmt.Println("Opening your browser to sign up for CircleCI...")
160+
decodedURL, _ := url.QueryUnescape(signupURL)
161+
fmt.Printf(" %s\n", decodedURL)
162+
163+
if err := browser.OpenURL(signupURL); err != nil {
164+
fmt.Printf("⚠️ Could not open browser automatically: %v\n", err)
165+
fmt.Printf(" Please manually visit: %s\n", decodedURL)
166+
}
167+
168+
fmt.Println("Waiting for authentication...")
169+
170+
// Wait for the token or an error, with a timeout.
171+
select {
172+
case token := <-tokenCh:
173+
_ = server.Shutdown(context.Background())
174+
trackSignupStep(cmd, "token_received", nil)
175+
return saveToken(opts.cfg, token)
176+
case err := <-errCh:
177+
_ = server.Shutdown(context.Background())
178+
trackSignupStep(cmd, "failed", nil)
179+
return errors.Wrap(err, "signup failed")
180+
case <-time.After(5 * time.Minute):
181+
_ = server.Shutdown(context.Background())
182+
trackSignupStep(cmd, "timeout", nil)
183+
return errors.New("timed out waiting for signup to complete. Run `circleci setup` to manually configure your CLI with a personal API token")
184+
}
185+
}
186+
187+
const allowedOrigin = "https://app.circleci.com"
188+
189+
// corsMiddleware validates the Origin header and adds CORS headers allowing
190+
// the CircleCI frontend to make cross-origin requests to the CLI's local server.
191+
// Requests with missing or non-matching Origin are rejected with 403.
192+
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
193+
return func(w http.ResponseWriter, r *http.Request) {
194+
origin := r.Header.Get("Origin")
195+
if origin != allowedOrigin {
196+
http.Error(w, "Forbidden", http.StatusForbidden)
197+
return
198+
}
199+
200+
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
201+
w.Header().Set("Access-Control-Allow-Methods", "GET")
202+
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
203+
w.Header().Set("Access-Control-Allow-Private-Network", "true")
204+
w.Header().Set("Access-Control-Max-Age", "300")
205+
206+
if r.Method == http.MethodOptions {
207+
w.WriteHeader(http.StatusNoContent)
208+
return
209+
}
210+
211+
next(w, r)
212+
}
213+
}
214+
215+
func stateMatches(a, b string) bool {
216+
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
217+
}
218+
219+
func handleToken(expectedState string, tokenCh chan<- string, errCh chan<- error) http.HandlerFunc {
220+
return func(w http.ResponseWriter, r *http.Request) {
221+
if r.Method != http.MethodGet {
222+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
223+
return
224+
}
225+
226+
query := r.URL.Query()
227+
token := query.Get("token")
228+
state := query.Get("cli_state")
229+
callbackErr := query.Get("error")
230+
231+
// When an error is present, state validation is best-effort: if state
232+
// is provided it must match, but a missing state is tolerated because
233+
// the frontend may not have had access to it when the failure occurred.
234+
if callbackErr != "" {
235+
if state != "" && !stateMatches(state, expectedState) {
236+
http.Error(w, "Invalid state", http.StatusForbidden)
237+
errCh <- errors.New("state mismatch — possible CSRF attempt")
238+
return
239+
}
240+
w.Header().Set("Content-Type", "application/json")
241+
json.NewEncoder(w).Encode(map[string]string{"status": "error"})
242+
errCh <- fmt.Errorf("authentication failed (%s). Run `circleci setup` to manually configure your CLI with a personal API token", callbackErr)
243+
return
244+
}
245+
246+
if !stateMatches(state, expectedState) {
247+
http.Error(w, "Invalid state", http.StatusForbidden)
248+
errCh <- errors.New("state mismatch — possible CSRF attempt")
249+
return
250+
}
251+
252+
if token == "" {
253+
http.Error(w, "Missing token", http.StatusBadRequest)
254+
errCh <- errors.New("callback returned an empty token. Run `circleci setup` to manually configure your CLI with a personal API token")
255+
return
256+
}
257+
258+
w.Header().Set("Content-Type", "application/json")
259+
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
260+
tokenCh <- token
261+
}
262+
}
263+
264+
func saveToken(cfg *settings.Config, token string) error {
265+
cfg.Token = token
266+
if err := cfg.WriteToDisk(); err != nil {
267+
return errors.Wrap(err, "failed to save token to config")
268+
}
269+
fmt.Println("\n✅ Welcome to CircleCI! Your CLI is now authenticated.")
270+
return nil
271+
}
272+
273+
func trackSignupStep(cmd *cobra.Command, step string, extra map[string]interface{}) {
274+
client, ok := telemetry.FromContext(cmd.Context())
275+
if !ok {
276+
return
277+
}
278+
invID, _ := telemetry.InvocationIDFromContext(cmd.Context())
279+
telemetry.TrackWorkflowStep(client, "signup", step, invID, extra)
280+
}

0 commit comments

Comments
 (0)