diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d7712b14..996a13a1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -43,7 +43,7 @@ jobs: - name: test uses: ./ci/image with: - args: go test ./internal/... ./cmd/... + args: go test -v -cover -covermode=count ./internal/... ./cmd/... gendocs: runs-on: ubuntu-latest steps: diff --git a/docs/coder_login.md b/docs/coder_login.md index 9cd18ae8..e943f275 100644 --- a/docs/coder_login.md +++ b/docs/coder_login.md @@ -7,7 +7,7 @@ Authenticate this client for future operations Authenticate this client for future operations ``` -coder login [Coder Enterprise URL eg. http://my.coder.domain/] [flags] +coder login [Coder Enterprise URL eg. https://my.coder.domain/] [flags] ``` ### Options diff --git a/go.mod b/go.mod index 6413afbc..871e8e2c 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 github.com/rjeczalik/notify v0.9.2 github.com/spf13/cobra v1.0.0 - github.com/stretchr/testify v1.6.1 // indirect + github.com/stretchr/testify v1.6.1 go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512 golang.org/x/crypto v0.0.0-20200422194213-44a606286825 golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a diff --git a/internal/cmd/login.go b/internal/cmd/login.go index d81dac0e..857a1bb1 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -1,83 +1,149 @@ package cmd import ( + "context" + "fmt" "net" "net/http" "net/url" + "os" "strings" - "sync" + "cdr.dev/coder-cli/coder-sdk" "cdr.dev/coder-cli/internal/config" "cdr.dev/coder-cli/internal/loginsrv" "github.com/pkg/browser" "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" "golang.org/x/xerrors" "go.coder.com/flog" ) func makeLoginCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "login [Coder Enterprise URL eg. http://my.coder.domain/]", + return &cobra.Command{ + Use: "login [Coder Enterprise URL eg. https://my.coder.domain/]", Short: "Authenticate this client for future operations", Args: cobra.ExactArgs(1), - RunE: login, - } - return cmd -} + RunE: func(cmd *cobra.Command, args []string) error { + // Pull the URL from the args and do some sanity check. + rawURL := args[0] + if rawURL == "" || !strings.HasPrefix(rawURL, "http") { + return xerrors.Errorf("invalid URL") + } + u, err := url.Parse(rawURL) + if err != nil { + return xerrors.Errorf("parse url: %w", err) + } + // Remove the trailing '/' if any. + u.Path = strings.TrimSuffix(u.Path, "/") + + // 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 { + flog.Error("Login error: %s.", err) + os.Exit(1) + } -func login(cmd *cobra.Command, args []string) error { - rawURL := args[0] - if rawURL == "" || !strings.HasPrefix(rawURL, "http") { - return xerrors.Errorf("invalid URL") + return nil + }, } +} - u, err := url.Parse(rawURL) +// 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 { - return xerrors.Errorf("parse url: %v", err) + if l, err = net.Listen("tcp6", "[::1]:0"); err != nil { + return nil, xerrors.Errorf("listen on a port: %w", err) + } } + return l, nil +} - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return xerrors.Errorf("create login server: %+v", err) +// pingAPI creates a client from the given url/token and try to exec an api call. +// Not using the SDK as we want to verify the url/token pair before storing the config files. +func pingAPI(ctx context.Context, envURL *url.URL, token string) error { + client := &coder.Client{BaseURL: envURL, Token: token} + if _, err := client.Me(ctx); err != nil { + return xerrors.Errorf("call api: %w", err) } - defer listener.Close() + return nil +} - srv := &loginsrv.Server{ - TokenCond: sync.NewCond(&sync.Mutex{}), +// 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) } - go func() { - _ = http.Serve( - listener, srv, - ) - }() - - err = config.URL.Write( - (&url.URL{Scheme: u.Scheme, Host: u.Host}).String(), - ) - if err != nil { - return xerrors.Errorf("write url: %v", err) + if err := sessionCfg.Write(sessionToken); err != nil { + return xerrors.Errorf("store session token: %w", err) } + return nil +} - authURL := url.URL{ - Scheme: u.Scheme, - Host: u.Host, - Path: "/internal-auth/", - RawQuery: "local_service=http://" + listener.Addr().String(), - } +func login(cmd *cobra.Command, envURL *url.URL, urlCfg, sessionCfg config.File) error { + ctx := cmd.Context() - err = browser.OpenURL(authURL.String()) + // 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. - flog.Info("visit %s to login", authURL.String()) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Visit the following URL in your browser:\n\n\t%s\n\n", &authURL) // Can't fail. } - srv.TokenCond.L.Lock() - srv.TokenCond.Wait() - err = config.Session.Write(srv.Token) - srv.TokenCond.L.Unlock() - if err != nil { - return xerrors.Errorf("set session: %v", err) + + // 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) } - flog.Success("logged in") + + // 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) + } + + flog.Success("Logged in.") + return nil } diff --git a/internal/config/file.go b/internal/config/file.go index bb83e746..8ef1a910 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -1,25 +1,25 @@ package config -// File provides convenience methods for interacting with *os.File +// File provides convenience methods for interacting with *os.File. type File string -// Delete deletes the file +// Delete deletes the file. func (f File) Delete() error { return rm(string(f)) } -// Write writes the string to the file +// Write writes the string to the file. func (f File) Write(s string) error { return write(string(f), 0600, []byte(s)) } -// Read reads the file to a string +// Read reads the file to a string. func (f File) Read() (string, error) { byt, err := read(string(f)) return string(byt), err } -// Coder CLI configuration files +// Coder CLI configuration files. var ( Session File = "session" URL File = "url" diff --git a/internal/loginsrv/input.go b/internal/loginsrv/input.go new file mode 100644 index 00000000..99de2015 --- /dev/null +++ b/internal/loginsrv/input.go @@ -0,0 +1,58 @@ +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 new file mode 100644 index 00000000..714ec86c --- /dev/null +++ b/internal/loginsrv/input_test.go @@ -0,0 +1,125 @@ +package loginsrv_test + +import ( + "context" + "fmt" + "io" + "io/ioutil" + "testing" + "time" + + "cdr.dev/coder-cli/internal/loginsrv" + "github.com/stretchr/testify/require" +) + +// 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: + require.Equal(t, testToken, actualToken, "Unexpected token received from readline.") + } + + select { + case <-ctx.Done(): + t.Fatal("Timeout waiting for readline to finish.") + case err := <-errChan: + require.NoError(t, err, "Error reading the line.") + } + }) + } +} + +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: + require.NoError(t, err, "Error reading the line.") + 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 index 2f8774c9..4add3775 100644 --- a/internal/loginsrv/server.go +++ b/internal/loginsrv/server.go @@ -3,28 +3,28 @@ package loginsrv import ( "fmt" "net/http" - "sync" ) -// Server waits for the login callback to send session token +// Server waits for the login callback to send the session token. type Server struct { - TokenCond *sync.Cond - Token string + TokenChan chan<- string } -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - token := r.URL.Query().Get("session_token") +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.Fprintf(w, "No session_token found") + _, _ = fmt.Fprintf(w, "No session_token found.\n") // Best effort. return } - s.TokenCond.L.Lock() - s.Token = token - s.TokenCond.L.Unlock() - s.TokenCond.Broadcast() - - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "You may close this window now") + select { + case <-ctx.Done(): + // Client disconnect. Nothing to do. + case srv.TokenChan <- token: + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf(w, "You may close this window now.\n") // Best effort. + } } diff --git a/internal/loginsrv/server_test.go b/internal/loginsrv/server_test.go new file mode 100644 index 00000000..694c68b6 --- /dev/null +++ b/internal/loginsrv/server_test.go @@ -0,0 +1,102 @@ +package loginsrv_test + +import ( + "context" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + "time" + + "cdr.dev/coder-cli/internal/loginsrv" + "github.com/stretchr/testify/require" +) + +// 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. + require.NoError(t, err, "Error creating the http request.") + + 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. + + require.Equal(t, http.StatusOK, resp.StatusCode, "Unexpected status code.") + + 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: + require.Equal(t, actualToken, actualToken, "Unexpected token received from the local server.") + } + + select { + case <-ctx.Done(): + t.Fatal("Timeout waiting for the handler to finish.") + case err := <-errChan: + require.NoError(t, err, "Error calling test server.") + 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, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil) // Can't fail. + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err, "Error calling test server.") + + _, _ = io.Copy(ioutil.Discard, resp.Body) // Ignore the body, worry about the response code. + _ = resp.Body.Close() // Best effort. + + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "Unexpected status code.") + 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. + } + }) +}