Skip to content

Commit ea6f381

Browse files
authored
src login command to help users configure authentication w/access token (#317)
1 parent cfc799e commit ea6f381

File tree

6 files changed

+259
-21
lines changed

6 files changed

+259
-21
lines changed

README.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,26 +63,36 @@ chmod +x /usr/local/bin/src
6363

6464
See [Sourcegraph CLI for Windows](WINDOWS.md).
6565

66-
## Setup with your Sourcegraph instance
66+
## Log into your Sourcegraph instance
6767

68-
### Via environment variables
68+
Run <code><strong>src login <i>SOURCEGRAPH-URL</i></strong></code> to authenticate `src` to access your Sourcegraph instance with your user credentials.
6969

70-
Point `src` to your instance and access token using environment variables:
70+
<blockquote>
7171

72-
```sh
73-
SRC_ENDPOINT=https://sourcegraph.example.com SRC_ACCESS_TOKEN="secret" src search 'foobar'
74-
```
72+
**Examples**
73+
`src login https://sourcegraph.example.com`
74+
`src login https://sourcegraph.com`
75+
76+
</blockquote>
77+
78+
`src` consults the following environment variables:
7579

76-
Sourcegraph behind a custom auth proxy? See [auth proxy configuration](./AUTH_PROXY.md) docs.
80+
- `SRC_ENDPOINT`: the URL to your Sourcegraph instance (such as `https://sourcegraph.example.com`)
81+
- `SRC_ACCESS_TOKEN`: your Sourcegraph access token (on your Sourcegraph instance, click your user menu in the top right, then select **Settings > Access tokens** to create one)
7782

78-
### Where to get an access token
83+
For convenience, you can export these environment variables in your shell profile. You can also inline them in a single command with:
84+
85+
```sh
86+
SRC_ENDPOINT=https://sourcegraph.example.com SRC_ACCESS_TOKEN=my-token src search 'foo'
87+
```
7988

80-
Visit your Sourcegraph instance (or https://sourcegraph.com), click your username in the top right to open the user menu, select **Settings**, and then select **Access tokens** in the left hand menu.
89+
Is your Sourcegraph instance behind a custom auth proxy? See [auth proxy configuration](./AUTH_PROXY.md) docs.
8190

8291
## Usage
8392

8493
`src` provides different subcommands to interact with different parts of Sourcegraph:
8594

95+
- `src login` - authenticate to a Sourcegraph instance with your user credentials
8696
- `src search` - perform searches and get results in your terminal or as JSON
8797
- `src actions` - run [campaign actions](https://docs.sourcegraph.com/user/campaigns/actions) to generate patch sets
8898
- `src api` - run Sourcegraph GraphQL API requests

cmd/src/login.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"flag"
7+
"fmt"
8+
"io"
9+
"io/ioutil"
10+
"os"
11+
"strings"
12+
)
13+
14+
func init() {
15+
usage := `'src login' helps you authenticate 'src' to access a Sourcegraph instance with your user credentials.
16+
17+
Usage:
18+
19+
src login SOURCEGRAPH_URL
20+
21+
Examples:
22+
23+
Authenticate to a Sourcegraph instance at https://sourcegraph.example.com:
24+
25+
$ src login https://sourcegraph.example.com
26+
27+
Authenticate to Sourcegraph.com:
28+
29+
$ src login https://sourcegraph.com
30+
`
31+
32+
flagSet := flag.NewFlagSet("login", flag.ExitOnError)
33+
usageFunc := func() {
34+
fmt.Fprintln(flag.CommandLine.Output(), usage)
35+
}
36+
37+
handler := func(args []string) error {
38+
if err := flagSet.Parse(args); err != nil {
39+
return err
40+
}
41+
if len(args) != 1 {
42+
return &usageError{errors.New("expected exactly one argument: the Sourcegraph URL")}
43+
}
44+
endpointArg := args[0]
45+
return loginCmd(context.Background(), cfg, endpointArg, os.Stdout)
46+
}
47+
48+
commands = append(commands, &command{
49+
flagSet: flagSet,
50+
handler: handler,
51+
usageFunc: usageFunc,
52+
})
53+
}
54+
55+
var exitCode1 = &exitCodeError{exitCode: 1}
56+
57+
func loginCmd(ctx context.Context, cfg *config, endpointArg string, out io.Writer) error {
58+
endpointArg = cleanEndpoint(endpointArg)
59+
60+
printProblem := func(problem string) {
61+
fmt.Fprintf(out, "❌ Problem: %s\n", problem)
62+
}
63+
64+
createAccessTokenMessage := fmt.Sprintf("\n"+`🛠 To fix: Create an access token at %s/user/settings/tokens, then set the following environment variables:
65+
66+
SRC_ENDPOINT=%s
67+
SRC_ACCESS_TOKEN=(the access token you just created)
68+
69+
To verify that it's working, run this command again.
70+
`, endpointArg, endpointArg)
71+
72+
if cfg.ConfigFilePath != "" {
73+
fmt.Fprintln(out)
74+
fmt.Fprintf(out, "⚠️ Warning: Configuring src with a JSON file is deprecated. Please migrate to using the env vars SRC_ENDPOINT and SRC_ACCESS_TOKEN instead, and then remove %s. See https://github.com/sourcegraph/src-cli#readme for more information.\n", cfg.ConfigFilePath)
75+
}
76+
77+
noToken := cfg.AccessToken == ""
78+
endpointConflict := endpointArg != cfg.Endpoint
79+
if noToken || endpointConflict {
80+
fmt.Fprintln(out)
81+
switch {
82+
case noToken:
83+
printProblem("No access token is configured.")
84+
case endpointConflict:
85+
printProblem(fmt.Sprintf("The configured endpoint is %s, not %s.", cfg.Endpoint, endpointArg))
86+
}
87+
fmt.Fprintln(out, createAccessTokenMessage)
88+
return exitCode1
89+
}
90+
91+
// See if the user is already authenticated.
92+
query := `query CurrentUser { currentUser { username } }`
93+
var result struct {
94+
CurrentUser *struct{ Username string }
95+
}
96+
client := cfg.apiClient(nil, ioutil.Discard)
97+
if _, err := client.NewRequest(query, nil).Do(ctx, &result); err != nil {
98+
if strings.HasPrefix(err.Error(), "error: 401 Unauthorized") || strings.HasPrefix(err.Error(), "error: 403 Forbidden") {
99+
printProblem("Invalid access token.")
100+
} else {
101+
printProblem(fmt.Sprintf("Error communicating with %s: %s", endpointArg, err))
102+
}
103+
fmt.Fprintln(out, createAccessTokenMessage)
104+
fmt.Fprintln(out, " (If you need to supply custom HTTP request headers, see information about SRC_HEADER_* env vars at https://github.com/sourcegraph/src-cli/blob/main/AUTH_PROXY.md.)")
105+
return exitCode1
106+
}
107+
108+
if result.CurrentUser == nil {
109+
// This should never happen; we verified there is an access token, so there should always be
110+
// a user.
111+
printProblem(fmt.Sprintf("Unable to determine user on %s.", endpointArg))
112+
return exitCode1
113+
}
114+
fmt.Fprintln(out)
115+
fmt.Fprintf(out, "✔️ Authenticated as %s on %s\n", result.CurrentUser.Username, endpointArg)
116+
fmt.Fprintln(out)
117+
return nil
118+
}

cmd/src/login_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
)
12+
13+
func TestLogin(t *testing.T) {
14+
check := func(t *testing.T, cfg *config, endpointArg string) (output string, err error) {
15+
t.Helper()
16+
17+
var out bytes.Buffer
18+
err = loginCmd(context.Background(), cfg, endpointArg, &out)
19+
return strings.TrimSpace(out.String()), err
20+
}
21+
22+
t.Run("different endpoint in config vs. arg", func(t *testing.T) {
23+
out, err := check(t, &config{Endpoint: "https://example.com"}, "https://sourcegraph.example.com")
24+
if err != exitCode1 {
25+
t.Fatal(err)
26+
}
27+
wantOut := "❌ Problem: No access token is configured.\n\n🛠 To fix: Create an access token at https://sourcegraph.example.com/user/settings/tokens, then set the following environment variables:\n\n SRC_ENDPOINT=https://sourcegraph.example.com\n SRC_ACCESS_TOKEN=(the access token you just created)\n\n To verify that it's working, run this command again."
28+
if out != wantOut {
29+
t.Errorf("got output %q, want %q", out, wantOut)
30+
}
31+
})
32+
33+
t.Run("no access token", func(t *testing.T) {
34+
out, err := check(t, &config{Endpoint: "https://example.com"}, "https://sourcegraph.example.com")
35+
if err != exitCode1 {
36+
t.Fatal(err)
37+
}
38+
wantOut := "❌ Problem: No access token is configured.\n\n🛠 To fix: Create an access token at https://sourcegraph.example.com/user/settings/tokens, then set the following environment variables:\n\n SRC_ENDPOINT=https://sourcegraph.example.com\n SRC_ACCESS_TOKEN=(the access token you just created)\n\n To verify that it's working, run this command again."
39+
if out != wantOut {
40+
t.Errorf("got output %q, want %q", out, wantOut)
41+
}
42+
})
43+
44+
t.Run("warning when using config file", func(t *testing.T) {
45+
out, err := check(t, &config{Endpoint: "https://example.com", ConfigFilePath: "f"}, "https://example.com")
46+
if err != exitCode1 {
47+
t.Fatal(err)
48+
}
49+
wantOut := "⚠️ Warning: Configuring src with a JSON file is deprecated. Please migrate to using the env vars SRC_ENDPOINT and SRC_ACCESS_TOKEN instead, and then remove f. See https://github.com/sourcegraph/src-cli#readme for more information.\n\n❌ Problem: No access token is configured.\n\n🛠 To fix: Create an access token at https://example.com/user/settings/tokens, then set the following environment variables:\n\n SRC_ENDPOINT=https://example.com\n SRC_ACCESS_TOKEN=(the access token you just created)\n\n To verify that it's working, run this command again."
50+
if out != wantOut {
51+
t.Errorf("got output %q, want %q", out, wantOut)
52+
}
53+
})
54+
55+
t.Run("invalid access token", func(t *testing.T) {
56+
// Dummy HTTP server to return HTTP 401/403.
57+
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58+
http.Error(w, "", http.StatusUnauthorized)
59+
}))
60+
defer s.Close()
61+
62+
endpoint := s.URL
63+
out, err := check(t, &config{Endpoint: endpoint, AccessToken: "x"}, endpoint)
64+
if err != exitCode1 {
65+
t.Fatal(err)
66+
}
67+
wantOut := "❌ Problem: Invalid access token.\n\n🛠 To fix: Create an access token at $ENDPOINT/user/settings/tokens, then set the following environment variables:\n\n SRC_ENDPOINT=$ENDPOINT\n SRC_ACCESS_TOKEN=(the access token you just created)\n\n To verify that it's working, run this command again.\n\n (If you need to supply custom HTTP request headers, see information about SRC_HEADER_* env vars at https://github.com/sourcegraph/src-cli/blob/main/AUTH_PROXY.md.)"
68+
wantOut = strings.Replace(wantOut, "$ENDPOINT", endpoint, -1)
69+
if out != wantOut {
70+
t.Errorf("got output %q, want %q", out, wantOut)
71+
}
72+
})
73+
74+
t.Run("valid", func(t *testing.T) {
75+
// Dummy HTTP server to return JSON response with currentUser.
76+
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77+
fmt.Fprintln(w, `{"data":{"currentUser":{"username":"alice"}}}`)
78+
}))
79+
defer s.Close()
80+
81+
endpoint := s.URL
82+
out, err := check(t, &config{Endpoint: endpoint, AccessToken: "x"}, endpoint)
83+
if err != nil {
84+
t.Fatal(err)
85+
}
86+
wantOut := "✔️ Authenticated as alice on $ENDPOINT"
87+
wantOut = strings.Replace(wantOut, "$ENDPOINT", endpoint, -1)
88+
if out != wantOut {
89+
t.Errorf("got output %q, want %q", out, wantOut)
90+
}
91+
})
92+
}

cmd/src/main.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ type config struct {
7575
Endpoint string `json:"endpoint"`
7676
AccessToken string `json:"accessToken"`
7777
AdditionalHeaders map[string]string `json:"additionalHeaders"`
78+
79+
ConfigFilePath string
7880
}
7981

8082
// apiClient returns an api.Client built from the configuration.
@@ -88,26 +90,36 @@ func (c *config) apiClient(flags *api.Flags, out io.Writer) api.Client {
8890
})
8991
}
9092

93+
var testHomeDir string // used by tests to mock the user's $HOME
94+
9195
// readConfig reads the config file from the given path.
9296
func readConfig() (*config, error) {
9397
cfgPath := *configPath
9498
userSpecified := *configPath != ""
9599

96-
u, err := user.Current()
97-
if err != nil {
98-
return nil, err
100+
var homeDir string
101+
if testHomeDir != "" {
102+
homeDir = testHomeDir
103+
} else {
104+
u, err := user.Current()
105+
if err != nil {
106+
return nil, err
107+
}
108+
homeDir = u.HomeDir
99109
}
110+
100111
if !userSpecified {
101-
cfgPath = filepath.Join(u.HomeDir, "src-config.json")
112+
cfgPath = filepath.Join(homeDir, "src-config.json")
102113
} else if strings.HasPrefix(cfgPath, "~/") {
103-
cfgPath = filepath.Join(u.HomeDir, cfgPath[2:])
114+
cfgPath = filepath.Join(homeDir, cfgPath[2:])
104115
}
105116
data, err := ioutil.ReadFile(os.ExpandEnv(cfgPath))
106117
if err != nil && (!os.IsNotExist(err) || userSpecified) {
107118
return nil, err
108119
}
109120
var cfg config
110121
if err == nil {
122+
cfg.ConfigFilePath = cfgPath
111123
if err := json.Unmarshal(data, &cfg); err != nil {
112124
return nil, err
113125
}
@@ -145,9 +157,13 @@ func readConfig() (*config, error) {
145157
cfg.Endpoint = *endpoint
146158
}
147159

148-
cfg.Endpoint = strings.TrimSuffix(cfg.Endpoint, "/")
160+
cfg.Endpoint = cleanEndpoint(cfg.Endpoint)
149161

150162
return &cfg, nil
151163
}
152164

165+
func cleanEndpoint(urlStr string) string {
166+
return strings.TrimSuffix(urlStr, "/")
167+
}
168+
153169
var errConfigMerge = errors.New("when using a configuration file, zero or all environment variables must be set")

cmd/src/main_test.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ func TestReadConfig(t *testing.T) {
154154
setEnv("SRC_ACCESS_TOKEN", test.envToken)
155155
setEnv("SRC_ENDPOINT", test.envEndpoint)
156156

157+
tmpDir, err := ioutil.TempDir("", "")
158+
if err != nil {
159+
t.Fatal(err)
160+
}
161+
testHomeDir = tmpDir
162+
t.Cleanup(func() { os.RemoveAll(tmpDir) })
163+
157164
if test.flagEndpoint != "" {
158165
val := test.flagEndpoint
159166
endpoint = &val
@@ -168,11 +175,6 @@ func TestReadConfig(t *testing.T) {
168175
if err != nil {
169176
t.Fatal(err)
170177
}
171-
tmpDir, err := ioutil.TempDir("", "")
172-
if err != nil {
173-
t.Fatal(err)
174-
}
175-
t.Cleanup(func() { os.RemoveAll(tmpDir) })
176178
filePath := filepath.Join(tmpDir, "config.json")
177179
err = ioutil.WriteFile(filePath, data, 0600)
178180
if err != nil {

internal/api/api.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ func (r *request) do(ctx context.Context, result interface{}) (bool, error) {
187187
if resp.StatusCode != http.StatusOK {
188188
if resp.StatusCode == http.StatusUnauthorized && isatty.IsCygwinTerminal(os.Stdout.Fd()) {
189189
fmt.Println("You may need to specify or update your access token to use this endpoint.")
190-
fmt.Println("See https://github.com/sourcegraph/src-cli#authentication")
190+
fmt.Println("See https://github.com/sourcegraph/src-cli#readme")
191191
fmt.Println("")
192192
}
193193
body, err := ioutil.ReadAll(resp.Body)

0 commit comments

Comments
 (0)