- Notifications
You must be signed in to change notification settings - Fork18
Manual token input during login.#124
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
coadler marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
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 | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,83 +1,149 @@ | ||
package cmd | ||
import ( | ||
"context" | ||
"fmt" | ||
"net" | ||
"net/http" | ||
"net/url" | ||
"os" | ||
"strings" | ||
"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 { | ||
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: 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. | ||
Comment on lines +41 to +42 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. I see what you're getting at here... not sure this is the best approach though. Because usage errors seem like the outlier, we could set There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. This seem a bit outside the scope of this PR as it is the way all commands are set. We can create a new PR to improve error management everywhere at once. | ||
if err := login(cmd, u, config.URL, config.Session); err != nil { | ||
flog.Error("Login error: %s.", err) | ||
os.Exit(1) | ||
} | ||
return nil | ||
}, | ||
} | ||
} | ||
// 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) | ||
} | ||
} | ||
return l, nil | ||
} | ||
// 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) | ||
} | ||
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) | ||
} | ||
flog.Success("Logged in.") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. Should be precede this with a newline? Right now, the output is:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. Added a newline in the prompt before this line. Otherwise, flog's prefix still is on he previous line. | ||
return nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
Uh oh!
There was an error while loading.Please reload this page.