-
Notifications
You must be signed in to change notification settings - Fork 18
Manual token input during login. #124
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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" | ||
"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. | ||
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) | ||
} | ||
|
||
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.") | ||
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.