Skip to content

Commit d249bc6

Browse files
[WEBXP-747] Switch circleci signup to server-side handshake polling
Replace the localhost HTTP callback server with a client-initiated polling loop against the auth-svc handshake endpoint. The CLI generates a UUID v4 handshake_id, opens the browser to /cli-auth?handshake_id=..., and polls GET /api/v1/cli-handshake/{id} every 3 seconds until the token arrives (200), the handshake expires (404), or the 10-minute timeout elapses. This eliminates the cross-origin / Private-Network / same-browser / MFA- interrupt failure modes of the prior localhost approach — the browser and CLI never communicate directly, so any device can complete auth. - Remove localhost listener, CORS/PNA middleware, state-nonce machinery, and the cli_port/cli_state/cli_label URL params. - Add UUID-based handshake_id, pollHandshake with retry on transient network errors, ctx-based Ctrl+C cancellation, 10-minute overall deadline, and a CIRCLECI_APP_URL env override for enterprise/testing. - --no-browser now prints the URL for cross-device use instead of prompting for a manual token paste (the handshake makes manual paste unnecessary). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 69de7c2 commit d249bc6

2 files changed

Lines changed: 217 additions & 397 deletions

File tree

cmd/signup.go

Lines changed: 130 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,38 @@ package cmd
22

33
import (
44
"context"
5-
"crypto/rand"
6-
"crypto/subtle"
7-
"encoding/hex"
85
"encoding/json"
6+
"errors"
97
"fmt"
10-
"net"
118
"net/http"
12-
"net/url"
139
"os"
14-
"strings"
10+
"os/signal"
1511
"time"
1612

13+
"github.com/google/uuid"
1714
"github.com/pkg/browser"
18-
"github.com/pkg/errors"
1915
"github.com/spf13/cobra"
2016

21-
"github.com/CircleCI-Public/circleci-cli/prompt"
2217
"github.com/CircleCI-Public/circleci-cli/settings"
2318
"github.com/CircleCI-Public/circleci-cli/telemetry"
2419
)
2520

21+
const (
22+
// App base URL override for enterprise / testing. Falls back to
23+
// defaultAppBaseURL when unset.
24+
appBaseURLEnv = "CIRCLECI_APP_URL"
25+
defaultAppBaseURL = "https://app.circleci.com"
26+
27+
handshakeTimeout = 10 * time.Minute
28+
handshakeHTTPTO = 10 * time.Second
29+
// Consecutive network errors tolerated before giving up.
30+
handshakeMaxNetErrs = 3
31+
)
32+
33+
// handshakePollWait is the delay between polls. It's a var so tests can
34+
// shorten it; production code should treat it as constant.
35+
var handshakePollWait = 3 * time.Second
36+
2637
type signupOptions struct {
2738
cfg *settings.Config
2839
noBrowser bool
@@ -49,7 +60,7 @@ func newSignupCommand(config *settings.Config) *cobra.Command {
4960
},
5061
}
5162

52-
cmd.Flags().BoolVar(&opts.noBrowser, "no-browser", false, "Don't open a browser; print the signup URL and prompt for a token instead")
63+
cmd.Flags().BoolVar(&opts.noBrowser, "no-browser", false, "Don't open a browserprint the signup URL so you can visit it from any device")
5364
cmd.Flags().BoolVar(&opts.force, "force", false, "Run signup even if already authenticated")
5465

5566
return cmd
@@ -70,218 +81,156 @@ func createSignupEvent(noBrowser bool, err error) telemetry.Event {
7081
}
7182
}
7283

84+
func appBaseURL() string {
85+
if v := os.Getenv(appBaseURLEnv); v != "" {
86+
return v
87+
}
88+
return defaultAppBaseURL
89+
}
90+
7391
func runSignup(cmd *cobra.Command, opts signupOptions) error {
7492
if !opts.force && opts.cfg.Token != "" {
7593
fmt.Println("You're already authenticated. Your CLI is configured with a personal API token.")
7694
fmt.Println("If you want to reconfigure, run `circleci setup`.")
7795
return nil
7896
}
7997

80-
state, err := generateState()
81-
if err != nil {
82-
return errors.Wrap(err, "failed to generate cryptographic state")
83-
}
98+
ctx, cancel := context.WithCancel(cmd.Context())
99+
defer cancel()
84100

85-
if opts.noBrowser {
86-
return signupNoBrowser(opts, state)
87-
}
101+
sigCh := make(chan os.Signal, 1)
102+
signal.Notify(sigCh, os.Interrupt)
103+
defer signal.Stop(sigCh)
104+
go func() {
105+
select {
106+
case <-sigCh:
107+
cancel()
108+
case <-ctx.Done():
109+
}
110+
}()
88111

89-
return signupWithBrowser(cmd, opts, state)
90-
}
112+
handshakeID := uuid.NewString()
113+
baseURL := appBaseURL()
114+
signupURL := fmt.Sprintf("%s/cli-auth?handshake_id=%s", baseURL, handshakeID)
91115

92-
func generateState() (string, error) {
93-
b := make([]byte, 16)
94-
if _, err := rand.Read(b); err != nil {
95-
return "", err
116+
if opts.noBrowser {
117+
fmt.Printf("To complete signup, open this URL on any device:\n\n %s\n\n", signupURL)
118+
} else {
119+
trackSignupStep(cmd, "browser_opening", nil)
120+
fmt.Println("Opening your browser to sign up for CircleCI...")
121+
fmt.Printf(" %s\n", signupURL)
122+
if err := browser.OpenURL(signupURL); err != nil {
123+
fmt.Printf("Could not open browser automatically: %v\n", err)
124+
fmt.Println("Please visit the URL above from any device.")
125+
}
96126
}
97-
return hex.EncodeToString(b), nil
98-
}
99127

100-
func signupNoBrowser(opts signupOptions, state string) error {
101-
signupURL := "https://app.circleci.com/authentication/login?f=gho&return-to=/settings/user/tokens"
102-
fmt.Printf("Open this URL in your browser to sign up:\n\n %s\n\n", signupURL)
128+
fmt.Println("Waiting for browser authentication...")
103129

104-
token, err := prompt.ReadSecretStringFromUser("Paste your CircleCI API token here")
130+
token, err := pollHandshake(ctx, baseURL, handshakeID, handshakeTimeout)
105131
if err != nil {
106-
return errors.Wrap(err, "failed to read token")
107-
}
108-
109-
if token == "" {
110-
return errors.New("no token provided")
132+
if ctx.Err() != nil {
133+
trackSignupStep(cmd, "canceled", nil)
134+
fmt.Println("\nAuthentication canceled.")
135+
return nil
136+
}
137+
trackSignupStep(cmd, "failed", nil)
138+
return fmt.Errorf("signup failed: %w", err)
111139
}
112140

141+
trackSignupStep(cmd, "token_received", nil)
113142
return saveToken(opts.cfg, token)
114143
}
115144

116-
func signupWithBrowser(cmd *cobra.Command, opts signupOptions, state string) error {
117-
// Start an ephemeral HTTP server on a random available port.
118-
listener, err := net.Listen("tcp", "127.0.0.1:0")
119-
if err != nil {
120-
return errors.Wrap(err, "failed to start local server")
121-
}
122-
port := listener.Addr().(*net.TCPAddr).Port
123-
124-
tokenCh := make(chan string, 1)
125-
errCh := make(chan error, 1)
126-
127-
mux := http.NewServeMux()
128-
mux.HandleFunc("/token", corsMiddleware(handleToken(state, tokenCh, errCh)))
145+
// pollHandshake polls the server-side handshake endpoint until a token appears
146+
// (200), the handshake expires (404), the context is cancelled, or the overall
147+
// timeout elapses. 202 responses mean "still pending"; transient network errors
148+
// are retried up to handshakeMaxNetErrs consecutive times.
149+
func pollHandshake(ctx context.Context, baseURL, handshakeID string, timeout time.Duration) (string, error) {
150+
client := &http.Client{Timeout: handshakeHTTPTO}
151+
endpoint := fmt.Sprintf("%s/api/v1/cli-handshake/%s", baseURL, handshakeID)
152+
153+
deadline := time.NewTimer(timeout)
154+
defer deadline.Stop()
155+
156+
var netErrs int
157+
for {
158+
token, status, err := handshakePoll(ctx, client, endpoint)
159+
switch {
160+
case err == nil && status == http.StatusOK:
161+
return token, nil
162+
case err == nil && status == http.StatusAccepted:
163+
netErrs = 0
164+
case err == nil && status == http.StatusNotFound:
165+
return "", errors.New("authentication expired or invalid handshake — run `circleci signup` to try again")
166+
case err == nil:
167+
return "", fmt.Errorf("unexpected response from handshake endpoint: %d", status)
168+
case ctx.Err() != nil:
169+
// Parent context was canceled or hit its deadline — surface it so
170+
// the caller can distinguish from transport-level timeouts.
171+
return "", ctx.Err()
172+
default:
173+
netErrs++
174+
if netErrs > handshakeMaxNetErrs {
175+
return "", fmt.Errorf("repeated network errors while polling for authentication: %w", err)
176+
}
177+
}
129178

130-
server := &http.Server{
131-
Handler: mux,
132-
ReadTimeout: 10 * time.Second,
133-
WriteTimeout: 10 * time.Second,
134-
}
179+
fmt.Print(".")
135180

136-
go func() {
137-
if serveErr := server.Serve(listener); serveErr != nil && serveErr != http.ErrServerClosed {
138-
errCh <- serveErr
181+
select {
182+
case <-ctx.Done():
183+
return "", ctx.Err()
184+
case <-deadline.C:
185+
return "", fmt.Errorf("timed out waiting for browser authentication (%s) — run `circleci signup` to try again", timeout)
186+
case <-time.After(handshakePollWait):
139187
}
140-
}()
188+
}
189+
}
141190

142-
// Generate a unique PAT label to avoid 422 duplicate errors when the
143-
// user runs signup multiple times or from different machines.
144-
hostname, err := os.Hostname()
191+
// handshakePoll performs a single GET against the handshake endpoint.
192+
// On 200 it decodes and returns the token; on any other status it returns the
193+
// status code for the caller to dispatch on. Network / transport errors surface
194+
// via the error return so the caller can decide whether to retry.
195+
func handshakePoll(ctx context.Context, client *http.Client, endpoint string) (string, int, error) {
196+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
145197
if err != nil {
146-
hostname = "unknown"
198+
return "", 0, err
147199
}
148-
label := fmt.Sprintf("circleci-cli-%s-%d", sanitizeHostname(hostname), time.Now().Unix())
149-
150-
// Build the signup URL. Go directly to /cli-auth (Magic Path).
151-
// The frontend checks for an existing session: if authenticated, it creates
152-
// the PAT immediately; if not, it redirects to signup first.
153-
params := url.Values{}
154-
params.Set("cli_port", fmt.Sprintf("%d", port))
155-
params.Set("cli_state", state)
156-
params.Set("cli_label", label)
157-
signupURL := "https://app.circleci.com/cli-auth?" + params.Encode()
158-
159-
trackSignupStep(cmd, "browser_opening", nil)
160-
fmt.Println("Opening your browser to sign up for CircleCI...")
161-
decodedURL, _ := url.QueryUnescape(signupURL)
162-
fmt.Printf(" %s\n", decodedURL)
163-
164-
if err := browser.OpenURL(signupURL); err != nil {
165-
fmt.Printf("⚠️ Could not open browser automatically: %v\n", err)
166-
fmt.Printf(" Please manually visit: %s\n", decodedURL)
167-
}
168-
169-
fmt.Println("Waiting for authentication...")
200+
req.Header.Set("Accept", "application/json")
170201

171-
// Wait for the token or an error, with a timeout.
172-
select {
173-
case token := <-tokenCh:
174-
_ = server.Shutdown(context.Background())
175-
trackSignupStep(cmd, "token_received", nil)
176-
return saveToken(opts.cfg, token)
177-
case err := <-errCh:
178-
_ = server.Shutdown(context.Background())
179-
trackSignupStep(cmd, "failed", nil)
180-
return errors.Wrap(err, "signup failed")
181-
case <-time.After(5 * time.Minute):
182-
_ = server.Shutdown(context.Background())
183-
trackSignupStep(cmd, "timeout", nil)
184-
return errors.New("timed out waiting for signup to complete. Run `circleci setup` to manually configure your CLI with a personal API token")
202+
resp, err := client.Do(req)
203+
if err != nil {
204+
return "", 0, err
185205
}
186-
}
187-
188-
const allowedOrigin = "https://app.circleci.com"
189-
190-
// corsMiddleware validates the Origin header and adds CORS headers allowing
191-
// the CircleCI frontend to make cross-origin requests to the CLI's local server.
192-
// Requests with missing or non-matching Origin are rejected with 403.
193-
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
194-
return func(w http.ResponseWriter, r *http.Request) {
195-
origin := r.Header.Get("Origin")
196-
if origin != allowedOrigin {
197-
http.Error(w, "Forbidden", http.StatusForbidden)
198-
return
199-
}
200-
201-
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
202-
w.Header().Set("Access-Control-Allow-Methods", "GET")
203-
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
204-
w.Header().Set("Access-Control-Allow-Private-Network", "true")
205-
w.Header().Set("Access-Control-Max-Age", "300")
206+
defer resp.Body.Close()
206207

207-
if r.Method == http.MethodOptions {
208-
w.WriteHeader(http.StatusNoContent)
209-
return
210-
}
211-
212-
next(w, r)
208+
if resp.StatusCode != http.StatusOK {
209+
return "", resp.StatusCode, nil
213210
}
214-
}
215211

216-
func stateMatches(a, b string) bool {
217-
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
218-
}
219-
220-
func handleToken(expectedState string, tokenCh chan<- string, errCh chan<- error) http.HandlerFunc {
221-
return func(w http.ResponseWriter, r *http.Request) {
222-
if r.Method != http.MethodGet {
223-
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
224-
return
225-
}
226-
227-
query := r.URL.Query()
228-
token := query.Get("token")
229-
state := query.Get("cli_state")
230-
callbackErr := query.Get("error")
231-
232-
// When an error is present, state validation is best-effort: if state
233-
// is provided it must match, but a missing state is tolerated because
234-
// the frontend may not have had access to it when the failure occurred.
235-
if callbackErr != "" {
236-
if state != "" && !stateMatches(state, expectedState) {
237-
http.Error(w, "Invalid state", http.StatusForbidden)
238-
errCh <- errors.New("state mismatch — possible CSRF attempt")
239-
return
240-
}
241-
w.Header().Set("Content-Type", "application/json")
242-
_ = json.NewEncoder(w).Encode(map[string]string{"status": "error"})
243-
errCh <- fmt.Errorf("authentication failed (%s). Run `circleci setup` to manually configure your CLI with a personal API token", callbackErr)
244-
return
245-
}
246-
247-
if !stateMatches(state, expectedState) {
248-
http.Error(w, "Invalid state", http.StatusForbidden)
249-
errCh <- errors.New("state mismatch — possible CSRF attempt")
250-
return
251-
}
252-
253-
if token == "" {
254-
http.Error(w, "Missing token", http.StatusBadRequest)
255-
errCh <- errors.New("callback returned an empty token. Run `circleci setup` to manually configure your CLI with a personal API token")
256-
return
257-
}
258-
259-
w.Header().Set("Content-Type", "application/json")
260-
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
261-
tokenCh <- token
212+
var body struct {
213+
Token string `json:"token"`
214+
CreatedAt string `json:"created_at"`
262215
}
263-
}
264-
265-
func sanitizeHostname(h string) string {
266-
var b strings.Builder
267-
for _, r := range h {
268-
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
269-
b.WriteRune(r)
270-
}
216+
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
217+
return "", resp.StatusCode, fmt.Errorf("failed to parse handshake response: %w", err)
271218
}
272-
s := b.String()
273-
if s == "" {
274-
return "unknown"
219+
if body.Token == "" {
220+
return "", resp.StatusCode, errors.New("handshake response contained no token")
275221
}
276-
return s
222+
return body.Token, resp.StatusCode, nil
277223
}
278224

279225
func saveToken(cfg *settings.Config, token string) error {
280226
cfg.Token = token
281227
if err := cfg.WriteToDisk(); err != nil {
282-
return errors.Wrap(err, "failed to save token to config")
228+
return fmt.Errorf("failed to save token to config: %w", err)
283229
}
284230
fmt.Println("\n✅ Welcome to CircleCI! Your CLI is now authenticated.")
231+
fmt.Println("\nNext steps:")
232+
fmt.Println(" circleci init — set up a project in the current directory")
233+
fmt.Println(" circleci help — see all available commands")
285234
return nil
286235
}
287236

0 commit comments

Comments
 (0)