Thanks to visit codestin.com
Credit goes to github.com

Skip to content
This repository was archived by the owner on Aug 30, 2024. It is now read-only.

Manual token input during login. #124

Merged
merged 1 commit into from
Sep 15, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/coder_login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
156 changes: 111 additions & 45 deletions internal/cmd/login.go
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
Copy link
Contributor

Choose a reason for hiding this comment

The 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 SilenceUsage: true and only print the usage when needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be precede this with a newline? Right now, the output is:

$ coder login https://master.cdr.dev
or enter token manually: 2020-09-08 16:04:30 SUCCESS    Logged in.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
}
10 changes: 5 additions & 5 deletions internal/config/file.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
58 changes: 58 additions & 0 deletions internal/loginsrv/input.go
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
}
Loading