diff --git a/internal/cmd/login.go b/internal/cmd/login.go index 7c782310..278c5b5f 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -1,21 +1,19 @@ package cmd import ( + "bufio" "context" "fmt" - "net" - "net/http" "net/url" + "os" "strings" "github.com/pkg/browser" "github.com/spf13/cobra" - "golang.org/x/sync/errgroup" "golang.org/x/xerrors" "cdr.dev/coder-cli/coder-sdk" "cdr.dev/coder-cli/internal/config" - "cdr.dev/coder-cli/internal/loginsrv" "cdr.dev/coder-cli/internal/version" "cdr.dev/coder-cli/internal/x/xcobra" "cdr.dev/coder-cli/pkg/clog" @@ -42,7 +40,7 @@ func loginCmd() *cobra.Command { // From this point, the commandline is correct. // Don't return errors as it would print the usage. - if err := login(cmd, u, config.URL, config.Session); err != nil { + if err := login(cmd.Context(), u); err != nil { return xerrors.Errorf("login error: %w", err) } return nil @@ -50,17 +48,47 @@ func loginCmd() *cobra.Command { } } -// newLocalListener creates up a local tcp server using port 0 (i.e. any available port). -// If ipv4 is disabled, try ipv6. -// It will be used by the http server waiting for the auth callback. -func newLocalListener() (net.Listener, error) { - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - if l, err = net.Listen("tcp6", "[::1]:0"); err != nil { - return nil, xerrors.Errorf("listen on a port: %w", err) - } +// storeConfig writes the env URL and session token to the local config directory. +// The config lib will handle the local config path lookup and creation. +func storeConfig(envURL *url.URL, sessionToken string, urlCfg, sessionCfg config.File) error { + if err := urlCfg.Write(envURL.String()); err != nil { + return xerrors.Errorf("store env url: %w", err) + } + if err := sessionCfg.Write(sessionToken); err != nil { + return xerrors.Errorf("store session token: %w", err) } - return l, nil + return nil +} + +func login(ctx context.Context, envURL *url.URL) error { + authURL := *envURL + authURL.Path = envURL.Path + "/internal-auth" + q := authURL.Query() + q.Add("show_token", "true") + authURL.RawQuery = q.Encode() + + if err := browser.OpenURL(authURL.String()); err != nil || true { + fmt.Printf("Open the following in your browser:\n\n\t%s\n\n", authURL.String()) + } else { + fmt.Printf("Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String()) + } + + token := readLine("Paste token here: ") + if err := pingAPI(ctx, envURL, token); err != nil { + return xerrors.Errorf("ping API with credentials: %w", err) + } + if err := storeConfig(envURL, token, config.URL, config.Session); err != nil { + return xerrors.Errorf("store auth: %w", err) + } + clog.LogSuccess("logged in") + return nil +} + +func readLine(prompt string) string { + reader := bufio.NewReader(os.Stdin) + fmt.Print(prompt) + text, _ := reader.ReadString('\n') + return strings.TrimSuffix(text, "\n") } // pingAPI creates a client from the given url/token and try to exec an api call. @@ -85,77 +113,3 @@ func pingAPI(ctx context.Context, envURL *url.URL, token string) error { } return nil } - -// storeConfig writes the env URL and session token to the local config directory. -// The config lib will handle the local config path lookup and creation. -func storeConfig(envURL *url.URL, sessionToken string, urlCfg, sessionCfg config.File) error { - if err := urlCfg.Write(envURL.String()); err != nil { - return xerrors.Errorf("store env url: %w", err) - } - if err := sessionCfg.Write(sessionToken); err != nil { - return xerrors.Errorf("store session token: %w", err) - } - return nil -} - -func login(cmd *cobra.Command, envURL *url.URL, urlCfg, sessionCfg config.File) error { - ctx := cmd.Context() - - // Start by creating the listener so we can prompt the user with the URL. - listener, err := newLocalListener() - if err != nil { - return xerrors.Errorf("create local listener: %w", err) - } - defer func() { _ = listener.Close() }() // Best effort. - - // Forge the auth URL with the callback set to the local server. - authURL := *envURL - authURL.Path = envURL.Path + "/internal-auth" - authURL.RawQuery = "local_service=http://" + listener.Addr().String() - - // Try to open the browser on the local computer. - if err := browser.OpenURL(authURL.String()); err != nil { - // Discard the error as it is an expected one in non-X environments like over ssh. - // Tell the user to visit the URL instead. - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Visit the following URL in your browser:\n\n\t%s\n\n", &authURL) // Can't fail. - } - - // Create our channel, it is going to be the central synchronization of the command. - tokenChan := make(chan string) - - // Create the http server outside the errgroup goroutine scope so we can stop it later. - srv := &http.Server{Handler: &loginsrv.Server{TokenChan: tokenChan}} - defer func() { _ = srv.Close() }() // Best effort. Direct close as we are dealing with a one-off request. - - // Start both the readline and http server in parallel. As they are both long-running routines, - // to know when to continue, we don't wait on the errgroup, but on the tokenChan. - group, ctx := errgroup.WithContext(ctx) - group.Go(func() error { return srv.Serve(listener) }) - group.Go(func() error { return loginsrv.ReadLine(ctx, cmd.InOrStdin(), cmd.ErrOrStderr(), tokenChan) }) - - // Only close then tokenChan when the errgroup is done. Best effort basis. - // Will not return the http route is used with a regular terminal. - // Useful for non interactive session, manual input, tests or custom stdin. - go func() { defer close(tokenChan); _ = group.Wait() }() - - var token string - select { - case <-ctx.Done(): - return ctx.Err() - case token = <-tokenChan: - } - - // Perform an API call to verify that the token is valid. - if err := pingAPI(ctx, envURL, token); err != nil { - return xerrors.Errorf("ping API: %w", err) - } - - // Success. Store the config only at this point so we don't override the local one in case of failure. - if err := storeConfig(envURL, token, urlCfg, sessionCfg); err != nil { - return xerrors.Errorf("store config: %w", err) - } - - clog.LogSuccess("logged in") - - return nil -} diff --git a/internal/loginsrv/input.go b/internal/loginsrv/input.go deleted file mode 100644 index cc0ae255..00000000 --- a/internal/loginsrv/input.go +++ /dev/null @@ -1,60 +0,0 @@ -// Package loginsrv defines the login server in use by coder-cli -// for performing the browser authentication flow. -package loginsrv - -import ( - "bufio" - "context" - "fmt" - "io" - "net/url" - "strings" - - "golang.org/x/xerrors" -) - -// ReadLine waits for the manual login input to send the session token. -// NOTE: As we are dealing with a Read, cancelling the context will not unblock. -// The caller is expected to close the reader. -func ReadLine(ctx context.Context, r io.Reader, w io.Writer, tokenChan chan<- string) error { - // Wrap the reader with bufio to simplify the readline. - buf := bufio.NewReader(r) - -retry: - _, _ = fmt.Fprintf(w, "or enter token manually:\n") // Best effort. Can only fail on custom writers. - line, err := buf.ReadString('\n') - if err != nil { - // If we get an expected error, discard it and stop the routine. - // NOTE: UnexpectedEOF is indeed an expected error as we can get it if we receive the token via the http server. - if err == io.EOF || err == io.ErrClosedPipe || err == io.ErrUnexpectedEOF { - return nil - } - // In the of error, we don't try again. Error out right away. - return xerrors.Errorf("read input: %w", err) - } - - // If we don't have any data, try again to read. - line = strings.TrimSpace(line) - if line == "" { - goto retry - } - - // Handle the case where we copy/paste the full URL instead of just the token. - // Useful as most browser will auto-select the full URL. - if u, err := url.Parse(line); err == nil { - // Check the query string only in case of success, ignore the error otherwise - // as we consider the input to be the token itself. - if token := u.Query().Get("session_token"); token != "" { - line = token - } - // If the session_token is missing, we also consider the input the be the token, don't error out. - } - - select { - case <-ctx.Done(): - return ctx.Err() - case tokenChan <- line: - } - - return nil -} diff --git a/internal/loginsrv/input_test.go b/internal/loginsrv/input_test.go deleted file mode 100644 index c17cb569..00000000 --- a/internal/loginsrv/input_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package loginsrv_test - -import ( - "context" - "fmt" - "io" - "io/ioutil" - "testing" - "time" - - "cdr.dev/slog/sloggers/slogtest/assert" - - "cdr.dev/coder-cli/internal/loginsrv" -) - -// 100ms is plenty of time as we are dealing with simple in-memory pipe. -const readTimeout = 100 * time.Millisecond - -func TestReadLine(t *testing.T) { - t.Parallel() - - const testToken = "hellosession" - - for _, scene := range []struct{ name, format string }{ - {"full_url", "http://localhost:4321?session_token=%s\n"}, - {"direct", "%s\n"}, - {"whitespaces", "\n\n %s \n\n"}, - } { - scene := scene - t.Run(scene.name, func(t *testing.T) { - t.Parallel() - - tokenChan := make(chan string) - defer close(tokenChan) - - ctx, cancel := context.WithTimeout(context.Background(), readTimeout) - defer cancel() - - r, w := io.Pipe() - defer func() { _, _ = r.Close(), w.Close() }() // Best effort. - - errChan := make(chan error) - go func() { defer close(errChan); errChan <- loginsrv.ReadLine(ctx, r, ioutil.Discard, tokenChan) }() - - doneChan := make(chan struct{}) - go func() { - defer close(doneChan) - _, _ = fmt.Fprintf(w, scene.format, testToken) // Best effort. - }() - - select { - case <-ctx.Done(): - t.Fatal("Timeout sending the input.") - case err := <-errChan: - t.Fatalf("ReadLine returned before we got the token (%v).", err) - case <-doneChan: - } - - select { - case <-ctx.Done(): - t.Fatal("Timeout waiting for the input.") - case err := <-errChan: - t.Fatalf("ReadLine returned before we got the token (%v).", err) - case actualToken := <-tokenChan: - assert.Equal(t, "Unexpected token received from readline.", testToken, actualToken) - } - - select { - case <-ctx.Done(): - t.Fatal("Timeout waiting for readline to finish.") - case err := <-errChan: - assert.Success(t, "Error reading the line.", err) - } - }) - } -} - -func TestReadLineMissingToken(t *testing.T) { - t.Parallel() - - tokenChan := make(chan string) - defer close(tokenChan) - - ctx, cancel := context.WithTimeout(context.Background(), readTimeout) - defer cancel() - - r, w := io.Pipe() - defer func() { _, _ = r.Close(), w.Close() }() // Best effort. - - errChan := make(chan error) - go func() { defer close(errChan); errChan <- loginsrv.ReadLine(ctx, r, ioutil.Discard, tokenChan) }() - - doneChan := make(chan struct{}) - go func() { - defer close(doneChan) - - // Send multiple empty lines. - for i := 0; i < 5; i++ { - _, _ = fmt.Fprint(w, "\n") // Best effort. - } - }() - - // Make sure the write doesn't timeout. - select { - case <-ctx.Done(): - t.Fatal("Timeout sending the input.") - case err := <-errChan: - t.Fatalf("ReadLine returned before we got the token (%+v).", err) - case token, ok := <-tokenChan: - t.Fatalf("Token channel unexpectedly unblocked. Data: %q, state: %t.", token, ok) - case <-doneChan: - } - - // Manually close the input. - _ = r.CloseWithError(io.EOF) // Best effort. - - // Make sure ReadLine properly ended. - select { - case <-ctx.Done(): - t.Fatal("Timeout waiting for readline to finish.") - case err := <-errChan: - assert.Success(t, "Error reading the line.", err) - case token, ok := <-tokenChan: - t.Fatalf("Token channel unexpectedly unblocked. Data: %q, state: %t.", token, ok) - } -} diff --git a/internal/loginsrv/server.go b/internal/loginsrv/server.go deleted file mode 100644 index b76d30d7..00000000 --- a/internal/loginsrv/server.go +++ /dev/null @@ -1,216 +0,0 @@ -package loginsrv - -import ( - "bytes" - "fmt" - "net/http" - "text/template" -) - -// Server waits for the login callback to send the session token. -type Server struct { - TokenChan chan<- string -} - -func loginRedirectHTMLDoc(message string, status string) string { - htmlTemplate := ` - - - - - - - - - -
- -
- - - - -
-

{{.Message}}

-
- - ` - - params := make(map[string]interface{}) - params["Message"] = message - params["Status"] = status - var tpl bytes.Buffer - t, err := template.New("htmlTemplate").Parse(htmlTemplate) - if err != nil { - return err.Error() - } - if err := t.Execute(&tpl, params); err != nil { - return err.Error() - } - result := tpl.String() - return result -} - -func (srv *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { - ctx := req.Context() - - token := req.URL.Query().Get("session_token") - if token == "" { - w.WriteHeader(http.StatusBadRequest) - _, _ = fmt.Fprint(w, loginRedirectHTMLDoc("No session_token found.", "off")) - return - } - - select { - case <-ctx.Done(): - // Client disconnect. Nothing to do. - case srv.TokenChan <- token: - w.Header().Set("Content-Type", "text/html; charset=UTF-8") - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, loginRedirectHTMLDoc("You are logged in, you may close this window now.", "on")) - } -} diff --git a/internal/loginsrv/server_test.go b/internal/loginsrv/server_test.go deleted file mode 100644 index 3730ee90..00000000 --- a/internal/loginsrv/server_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package loginsrv_test - -import ( - "context" - "io" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - "time" - - "cdr.dev/slog/sloggers/slogtest/assert" - - "cdr.dev/coder-cli/internal/loginsrv" -) - -// 500ms should be plenty enough, even on slow machine to perform the request/response cycle. -const httpTimeout = 500 * time.Millisecond - -func TestLocalLoginHTTPServer(t *testing.T) { - t.Parallel() - - t.Run("happy_path", func(t *testing.T) { - t.Parallel() - - tokenChan := make(chan string) - defer close(tokenChan) - - ts := httptest.NewServer(&loginsrv.Server{TokenChan: tokenChan}) - defer ts.Close() - - ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) - defer cancel() - - const testToken = "hellosession" - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"?session_token="+testToken, nil) // Can't fail. - assert.Success(t, "Error creating the http request", err) - - errChan := make(chan error) - go func() { - defer close(errChan) - resp, err := http.DefaultClient.Do(req) - - _, _ = io.Copy(ioutil.Discard, resp.Body) // Ignore the body, worry about the response code. - _ = resp.Body.Close() // Best effort. - - assert.Equal(t, "Unexpected status code", http.StatusOK, resp.StatusCode) - - errChan <- err - }() - - select { - case <-ctx.Done(): - t.Fatal("Timeout waiting for the session token.") - case err := <-errChan: - t.Fatalf("The HTTP client returned before we got the token (%+v).", err) - case actualToken := <-tokenChan: - assert.Equal(t, "Unexpected token received from the local server.", testToken, actualToken) - } - - select { - case <-ctx.Done(): - t.Fatal("Timeout waiting for the handler to finish.") - case err := <-errChan: - assert.Success(t, "Error calling test server", err) - if t.Failed() { // Case where the assert within the goroutine failed. - return - } - } - }) - - t.Run("missing_token", func(t *testing.T) { - t.Parallel() - - tokenChan := make(chan string) - defer close(tokenChan) - - ts := httptest.NewServer(&loginsrv.Server{TokenChan: tokenChan}) - defer ts.Close() - - ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) // Can't fail. - assert.Success(t, "Error creating the http request", err) - - resp, err := http.DefaultClient.Do(req) - assert.Success(t, "Error calling test server", err) - - _, _ = io.Copy(ioutil.Discard, resp.Body) // Ignore the body, worry about the response code. - _ = resp.Body.Close() // Best effort. - - assert.Equal(t, "Unexpected status code", http.StatusBadRequest, resp.StatusCode) - select { - case err := <-ctx.Done(): - t.Fatalf("Unexpected context termination: %s.", err) - case token, ok := <-tokenChan: - t.Fatalf("Token channel unexpectedly unblocked. Data: %q, state: %t.", token, ok) - default: - // Expected case: valid and live context. - } - }) -}