@@ -2,27 +2,38 @@ package cmd
22
33import (
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+
2637type 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 browser — print 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+
7391func 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 ("\n Authentication 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
279225func 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 ("\n Next 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