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

Commit 8b35b02

Browse files
committed
- Allow for manual input of token during login.
- Support path based reverse proxy. Signed-off-by: Guillaume J. Charmes <guillaume@coder.com>
1 parent c796bca commit 8b35b02

File tree

9 files changed

+418
-67
lines changed

9 files changed

+418
-67
lines changed

.github/workflows/test.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ jobs:
4343
- name: test
4444
uses: ./ci/image
4545
with:
46-
args: go test ./internal/... ./cmd/...
46+
args: go test -v -cover -covermode=count ./internal/... ./cmd/...
4747
gendocs:
4848
runs-on: ubuntu-latest
4949
steps:

docs/coder_login.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Authenticate this client for future operations
77
Authenticate this client for future operations
88

99
```
10-
coder login [Coder Enterprise URL eg. http://my.coder.domain/] [flags]
10+
coder login [Coder Enterprise URL eg. https://my.coder.domain/] [flags]
1111
```
1212

1313
### Options

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ require (
1414
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4
1515
github.com/rjeczalik/notify v0.9.2
1616
github.com/spf13/cobra v1.0.0
17-
github.com/stretchr/testify v1.6.1 // indirect
17+
github.com/stretchr/testify v1.6.1
1818
go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512
1919
golang.org/x/crypto v0.0.0-20200422194213-44a606286825
2020
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a

internal/cmd/login.go

+111-45
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,149 @@
11
package cmd
22

33
import (
4+
"context"
5+
"fmt"
46
"net"
57
"net/http"
68
"net/url"
9+
"os"
710
"strings"
8-
"sync"
911

12+
"cdr.dev/coder-cli/coder-sdk"
1013
"cdr.dev/coder-cli/internal/config"
1114
"cdr.dev/coder-cli/internal/loginsrv"
1215
"github.com/pkg/browser"
1316
"github.com/spf13/cobra"
17+
"golang.org/x/sync/errgroup"
1418
"golang.org/x/xerrors"
1519

1620
"go.coder.com/flog"
1721
)
1822

1923
func makeLoginCmd() *cobra.Command {
20-
cmd := &cobra.Command{
21-
Use: "login [Coder Enterprise URL eg. http://my.coder.domain/]",
24+
return &cobra.Command{
25+
Use: "login [Coder Enterprise URL eg. https://my.coder.domain/]",
2226
Short: "Authenticate this client for future operations",
2327
Args: cobra.ExactArgs(1),
24-
RunE: login,
25-
}
26-
return cmd
27-
}
28+
RunE: func(cmd *cobra.Command, args []string) error {
29+
// Pull the URL from the args and do some sanity check.
30+
rawURL := args[0]
31+
if rawURL == "" || !strings.HasPrefix(rawURL, "http") {
32+
return xerrors.Errorf("invalid URL")
33+
}
34+
u, err := url.Parse(rawURL)
35+
if err != nil {
36+
return xerrors.Errorf("parse url: %w", err)
37+
}
38+
// Remove the trailing '/' if any.
39+
u.Path = strings.TrimSuffix(u.Path, "/")
40+
41+
// From this point, the commandline is correct.
42+
// Don't return errors as it would print the usage.
43+
44+
if err := login(cmd, u, config.URL, config.Session); err != nil {
45+
flog.Error("Login error: %s.", err)
46+
os.Exit(1)
47+
}
2848

29-
func login(cmd *cobra.Command, args []string) error {
30-
rawURL := args[0]
31-
if rawURL == "" || !strings.HasPrefix(rawURL, "http") {
32-
return xerrors.Errorf("invalid URL")
49+
return nil
50+
},
3351
}
52+
}
3453

35-
u, err := url.Parse(rawURL)
54+
// newLocalListener creates up a local tcp server using port 0 (i.e. any available port).
55+
// If ipv4 is disabled, try ipv6.
56+
// It will be used by the http server waiting for the auth callback.
57+
func newLocalListener() (net.Listener, error) {
58+
l, err := net.Listen("tcp", "127.0.0.1:0")
3659
if err != nil {
37-
return xerrors.Errorf("parse url: %v", err)
60+
if l, err = net.Listen("tcp6", "[::1]:0"); err != nil {
61+
return nil, xerrors.Errorf("listen on a port: %w", err)
62+
}
3863
}
64+
return l, nil
65+
}
3966

40-
listener, err := net.Listen("tcp", "127.0.0.1:0")
41-
if err != nil {
42-
return xerrors.Errorf("create login server: %+v", err)
67+
// pingAPI creates a client from the given url/token and try to exec an api call.
68+
// Not using the SDK as we want to verify the url/token pair before storing the config files.
69+
func pingAPI(ctx context.Context, envURL *url.URL, token string) error {
70+
client := &coder.Client{BaseURL: envURL, Token: token}
71+
if _, err := client.Me(ctx); err != nil {
72+
return xerrors.Errorf("call api: %w", err)
4373
}
44-
defer listener.Close()
74+
return nil
75+
}
4576

46-
srv := &loginsrv.Server{
47-
TokenCond: sync.NewCond(&sync.Mutex{}),
77+
// storeConfig writes the env URL and session token to the local config directory.
78+
// The config lib will handle the local config path lookup and creation.
79+
func storeConfig(envURL *url.URL, sessionToken string, urlCfg, sessionCfg config.File) error {
80+
if err := urlCfg.Write(envURL.String()); err != nil {
81+
return xerrors.Errorf("store env url: %w", err)
4882
}
49-
go func() {
50-
_ = http.Serve(
51-
listener, srv,
52-
)
53-
}()
54-
55-
err = config.URL.Write(
56-
(&url.URL{Scheme: u.Scheme, Host: u.Host}).String(),
57-
)
58-
if err != nil {
59-
return xerrors.Errorf("write url: %v", err)
83+
if err := sessionCfg.Write(sessionToken); err != nil {
84+
return xerrors.Errorf("store session token: %w", err)
6085
}
86+
return nil
87+
}
6188

62-
authURL := url.URL{
63-
Scheme: u.Scheme,
64-
Host: u.Host,
65-
Path: "/internal-auth/",
66-
RawQuery: "local_service=http://" + listener.Addr().String(),
67-
}
89+
func login(cmd *cobra.Command, envURL *url.URL, urlCfg, sessionCfg config.File) error {
90+
ctx := cmd.Context()
6891

69-
err = browser.OpenURL(authURL.String())
92+
// Start by creating the listener so we can prompt the user with the URL.
93+
listener, err := newLocalListener()
7094
if err != nil {
95+
return xerrors.Errorf("create local listener: %w", err)
96+
}
97+
defer func() { _ = listener.Close() }() // Best effort.
98+
99+
// Forge the auth URL with the callback set to the local server.
100+
authURL := *envURL
101+
authURL.Path = envURL.Path + "/internal-auth"
102+
authURL.RawQuery = "local_service=http://" + listener.Addr().String()
103+
104+
// Try to open the browser on the local computer.
105+
if err := browser.OpenURL(authURL.String()); err != nil {
106+
// Discard the error as it is an expected one in non-X environments like over ssh.
71107
// Tell the user to visit the URL instead.
72-
flog.Info("visit %s to login", authURL.String())
108+
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Visit the following URL in your browser:\n\n\t%s\n\n", &authURL) // Can't fail.
73109
}
74-
srv.TokenCond.L.Lock()
75-
srv.TokenCond.Wait()
76-
err = config.Session.Write(srv.Token)
77-
srv.TokenCond.L.Unlock()
78-
if err != nil {
79-
return xerrors.Errorf("set session: %v", err)
110+
111+
// Create our channel, it is going to be the central synchronization of the command.
112+
tokenChan := make(chan string)
113+
114+
// Create the http server outside the errgroup goroutine scope so we can stop it later.
115+
srv := &http.Server{Handler: &loginsrv.Server{TokenChan: tokenChan}}
116+
defer func() { _ = srv.Close() }() // Best effort. Direct close as we are dealing with a one-off request.
117+
118+
// Start both the readline and http server in parallel. As they are both long-running routines,
119+
// to know when to continue, we don't wait on the errgroup, but on the tokenChan.
120+
group, ctx := errgroup.WithContext(ctx)
121+
group.Go(func() error { return srv.Serve(listener) })
122+
group.Go(func() error { return loginsrv.ReadLine(ctx, cmd.InOrStdin(), cmd.ErrOrStderr(), tokenChan) })
123+
124+
// Only close then tokenChan when the errgroup is done. Best effort basis.
125+
// Will not return the http route is used with a regular terminal.
126+
// Useful for non interactive session, manual input, tests or custom stdin.
127+
go func() { defer close(tokenChan); _ = group.Wait() }()
128+
129+
var token string
130+
select {
131+
case <-ctx.Done():
132+
return ctx.Err()
133+
case token = <-tokenChan:
134+
}
135+
136+
// Perform an API call to verify that the token is valid.
137+
if err := pingAPI(ctx, envURL, token); err != nil {
138+
return xerrors.Errorf("ping API: %w", err)
80139
}
81-
flog.Success("logged in")
140+
141+
// Success. Store the config only at this point so we don't override the local one in case of failure.
142+
if err := storeConfig(envURL, token, urlCfg, sessionCfg); err != nil {
143+
return xerrors.Errorf("store config: %w", err)
144+
}
145+
146+
flog.Success("Logged in.")
147+
82148
return nil
83149
}

internal/config/file.go

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
11
package config
22

3-
// File provides convenience methods for interacting with *os.File
3+
// File provides convenience methods for interacting with *os.File.
44
type File string
55

6-
// Delete deletes the file
6+
// Delete deletes the file.
77
func (f File) Delete() error {
88
return rm(string(f))
99
}
1010

11-
// Write writes the string to the file
11+
// Write writes the string to the file.
1212
func (f File) Write(s string) error {
1313
return write(string(f), 0600, []byte(s))
1414
}
1515

16-
// Read reads the file to a string
16+
// Read reads the file to a string.
1717
func (f File) Read() (string, error) {
1818
byt, err := read(string(f))
1919
return string(byt), err
2020
}
2121

22-
// Coder CLI configuration files
22+
// Coder CLI configuration files.
2323
var (
2424
Session File = "session"
2525
URL File = "url"

internal/loginsrv/input.go

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package loginsrv
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"io"
8+
"net/url"
9+
"strings"
10+
11+
"golang.org/x/xerrors"
12+
)
13+
14+
// ReadLine waits for the manual login input to send the session token.
15+
// NOTE: As we are dealing with a Read, cancelling the context will not unblock.
16+
// The caller is expected to close the reader.
17+
func ReadLine(ctx context.Context, r io.Reader, w io.Writer, tokenChan chan<- string) error {
18+
// Wrap the reader with bufio to simplify the readline.
19+
buf := bufio.NewReader(r)
20+
21+
retry:
22+
_, _ = fmt.Fprintf(w, "or enter token manually:\n") // Best effort. Can only fail on custom writers.
23+
line, err := buf.ReadString('\n')
24+
if err != nil {
25+
// If we get an expected error, discard it and stop the routine.
26+
// NOTE: UnexpectedEOF is indeed an expected error as we can get it if we receive the token via the http server.
27+
if err == io.EOF || err == io.ErrClosedPipe || err == io.ErrUnexpectedEOF {
28+
return nil
29+
}
30+
// In the of error, we don't try again. Error out right away.
31+
return xerrors.Errorf("read input: %w", err)
32+
}
33+
34+
// If we don't have any data, try again to read.
35+
line = strings.TrimSpace(line)
36+
if line == "" {
37+
goto retry
38+
}
39+
40+
// Handle the case where we copy/paste the full URL instead of just the token.
41+
// Useful as most browser will auto-select the full URL.
42+
if u, err := url.Parse(line); err == nil {
43+
// Check the query string only in case of success, ignore the error otherwise
44+
// as we consider the input to be the token itself.
45+
if token := u.Query().Get("session_token"); token != "" {
46+
line = token
47+
}
48+
// If the session_token is missing, we also consider the input the be the token, don't error out.
49+
}
50+
51+
select {
52+
case <-ctx.Done():
53+
return ctx.Err()
54+
case tokenChan <- line:
55+
}
56+
57+
return nil
58+
}

0 commit comments

Comments
 (0)