Skip to content

Commit eb8f159

Browse files
committed
refactor: login
- split login into distinct flows - add specific flow for oauth - add specific flows for error conditions - update tests
1 parent 0bc535e commit eb8f159

File tree

4 files changed

+314
-152
lines changed

4 files changed

+314
-152
lines changed

cmd/src/login.go

Lines changed: 59 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,6 @@ import (
66
"fmt"
77
"io"
88
"os"
9-
"os/exec"
10-
"runtime"
11-
"strings"
12-
"time"
139

1410
"github.com/sourcegraph/src-cli/internal/api"
1511
"github.com/sourcegraph/src-cli/internal/cmderrors"
@@ -69,13 +65,13 @@ Examples:
6965
client := cfg.apiClient(apiFlags, io.Discard)
7066

7167
return loginCmd(context.Background(), loginParams{
72-
cfg: cfg,
73-
client: client,
74-
endpoint: endpoint,
75-
out: os.Stdout,
76-
useOAuth: *useOAuth,
77-
apiFlags: apiFlags,
78-
deviceFlowClient: oauth.NewClient(oauth.DefaultClientID),
68+
cfg: cfg,
69+
client: client,
70+
endpoint: endpoint,
71+
out: os.Stdout,
72+
useOAuth: *useOAuth,
73+
apiFlags: apiFlags,
74+
oauthClient: oauth.NewClient(oauth.DefaultClientID),
7975
})
8076
}
8177

@@ -87,161 +83,80 @@ Examples:
8783
}
8884

8985
type loginParams struct {
90-
cfg *config
91-
client api.Client
92-
endpoint string
93-
out io.Writer
94-
useOAuth bool
95-
apiFlags *api.Flags
96-
deviceFlowClient oauth.Client
86+
cfg *config
87+
client api.Client
88+
endpoint string
89+
out io.Writer
90+
useOAuth bool
91+
apiFlags *api.Flags
92+
oauthClient oauth.Client
9793
}
9894

99-
func loginCmd(ctx context.Context, p loginParams) error {
100-
endpointArg := cleanEndpoint(p.endpoint)
101-
cfg := p.cfg
102-
client := p.client
103-
out := p.out
104-
105-
printProblem := func(problem string) {
106-
fmt.Fprintf(out, "❌ Problem: %s\n", problem)
107-
}
108-
109-
createAccessTokenMessage := fmt.Sprintf("\n"+`🛠 To fix: Create an access token by going to %s/user/settings/tokens, then set the following environment variables in your terminal:
95+
type loginFlow func(context.Context, loginParams) error
11096

111-
export SRC_ENDPOINT=%s
112-
export SRC_ACCESS_TOKEN=(your access token)
97+
type loginFlowKind int
11398

114-
To verify that it's working, run the login command again.
115-
116-
Alternatively, you can try logging in using OAuth by running: src login --oauth %s
117-
`, endpointArg, endpointArg, endpointArg)
99+
const (
100+
loginFlowOAuth loginFlowKind = iota
101+
loginFlowMissingAuth
102+
loginFlowEndpointConflict
103+
loginFlowValidate
104+
)
118105

119-
if cfg.ConfigFilePath != "" {
120-
fmt.Fprintln(out)
121-
fmt.Fprintf(out, "⚠️ Warning: Configuring src with a JSON file is deprecated. Please migrate to using the env vars SRC_ENDPOINT, SRC_ACCESS_TOKEN, and SRC_PROXY instead, and then remove %s. See https://github.com/sourcegraph/src-cli#readme for more information.\n", cfg.ConfigFilePath)
122-
}
106+
var loadStoredOAuthToken = oauth.LoadToken
123107

124-
noToken := cfg.AccessToken == ""
125-
endpointConflict := endpointArg != cfg.Endpoint
126-
if !p.useOAuth && (noToken || endpointConflict) {
127-
fmt.Fprintln(out)
128-
switch {
129-
case noToken:
130-
printProblem("No access token is configured.")
131-
case endpointConflict:
132-
printProblem(fmt.Sprintf("The configured endpoint is %s, not %s.", cfg.Endpoint, endpointArg))
133-
}
134-
fmt.Fprintln(out, createAccessTokenMessage)
135-
return cmderrors.ExitCode1
108+
func loginCmd(ctx context.Context, p loginParams) error {
109+
if p.cfg.ConfigFilePath != "" {
110+
fmt.Fprintln(p.out)
111+
fmt.Fprintf(p.out, "⚠️ Warning: Configuring src with a JSON file is deprecated. Please migrate to using the env vars SRC_ENDPOINT, SRC_ACCESS_TOKEN, and SRC_PROXY instead, and then remove %s. See https://github.com/sourcegraph/src-cli#readme for more information.\n", p.cfg.ConfigFilePath)
136112
}
137113

138-
if p.useOAuth {
139-
token, err := runOAuthDeviceFlow(ctx, endpointArg, out, p.deviceFlowClient)
140-
if err != nil {
141-
printProblem(fmt.Sprintf("OAuth Device flow authentication failed: %s", err))
142-
fmt.Fprintln(out, createAccessTokenMessage)
143-
return cmderrors.ExitCode1
144-
}
145-
146-
if err := oauth.StoreToken(ctx, token); err != nil {
147-
fmt.Fprintln(out)
148-
fmt.Fprintf(out, "⚠️ Warning: Failed to store token in keyring store: %q. Continuing with this session only.\n", err)
149-
}
114+
_, flow := selectLoginFlow(ctx, p)
115+
return flow(ctx, p)
116+
}
150117

151-
client = api.NewClient(api.ClientOpts{
152-
Endpoint: cfg.Endpoint,
153-
AdditionalHeaders: cfg.AdditionalHeaders,
154-
Flags: p.apiFlags,
155-
Out: out,
156-
ProxyURL: cfg.ProxyURL,
157-
ProxyPath: cfg.ProxyPath,
158-
OAuthToken: token,
159-
})
160-
}
118+
// selectLoginFlow decides what login flow to run based on flags and config.
119+
func selectLoginFlow(ctx context.Context, p loginParams) (loginFlowKind, loginFlow) {
120+
endpointArg := cleanEndpoint(p.endpoint)
161121

162-
// See if the user is already authenticated.
163-
query := `query CurrentUser { currentUser { username } }`
164-
var result struct {
165-
CurrentUser *struct{ Username string }
166-
}
167-
if _, err := client.NewRequest(query, nil).Do(ctx, &result); err != nil {
168-
if strings.HasPrefix(err.Error(), "error: 401 Unauthorized") || strings.HasPrefix(err.Error(), "error: 403 Forbidden") {
169-
printProblem("Invalid access token.")
170-
} else {
171-
printProblem(fmt.Sprintf("Error communicating with %s: %s", endpointArg, err))
172-
}
173-
fmt.Fprintln(out, createAccessTokenMessage)
174-
fmt.Fprintln(out, " (If you need to supply custom HTTP request headers, see information about SRC_HEADER_* and SRC_HEADERS env vars at https://github.com/sourcegraph/src-cli/blob/main/AUTH_PROXY.md)")
175-
return cmderrors.ExitCode1
122+
if p.useOAuth {
123+
return loginFlowOAuth, runOAuthLogin
176124
}
177-
178-
if result.CurrentUser == nil {
179-
// This should never happen; we verified there is an access token, so there should always be
180-
// a user.
181-
printProblem(fmt.Sprintf("Unable to determine user on %s.", endpointArg))
182-
return cmderrors.ExitCode1
125+
if !hasEffectiveAuth(ctx, p.cfg, endpointArg) {
126+
return loginFlowMissingAuth, runMissingAuthLogin
183127
}
184-
fmt.Fprintln(out)
185-
fmt.Fprintf(out, "✔️ Authenticated as %s on %s\n", result.CurrentUser.Username, endpointArg)
186-
187-
if p.useOAuth {
188-
fmt.Fprintln(out)
189-
fmt.Fprintf(out, "Authenticated with OAuth credentials")
128+
if endpointArg != p.cfg.Endpoint {
129+
return loginFlowEndpointConflict, runEndpointConflictLogin
190130
}
191-
192-
fmt.Fprintln(out)
193-
return nil
131+
return loginFlowValidate, runValidatedLogin
194132
}
195133

196-
func runOAuthDeviceFlow(ctx context.Context, endpoint string, out io.Writer, client oauth.Client) (*oauth.Token, error) {
197-
authResp, err := client.Start(ctx, endpoint, nil)
198-
if err != nil {
199-
return nil, err
134+
// hasEffectiveAuth determines whether we have auth credentials to continue. It first checks for a resolved Access Token in
135+
// config, then it checks for a stored OAuth token.
136+
func hasEffectiveAuth(ctx context.Context, cfg *config, resolvedEndpoint string) bool {
137+
if cfg.AccessToken != "" {
138+
return true
200139
}
201140

202-
authURL := authResp.VerificationURIComplete
203-
msg := fmt.Sprintf("If your browser did not open automatically, visit %s.", authURL)
204-
if authURL == "" {
205-
authURL = authResp.VerificationURI
206-
msg = fmt.Sprintf("If your browser did not open automatically, visit %s and enter the user code %s", authURL, authResp.DeviceCode)
141+
if _, err := loadStoredOAuthToken(ctx, resolvedEndpoint); err == nil {
142+
return true
207143
}
208-
_ = openInBrowser(authURL)
209-
fmt.Fprintln(out)
210-
fmt.Fprint(out, msg)
211144

212-
fmt.Fprintln(out)
213-
fmt.Fprint(out, "Waiting for authorization...")
214-
defer fmt.Fprintf(out, "DONE\n\n")
145+
return false
146+
}
215147

216-
interval := time.Duration(authResp.Interval) * time.Second
217-
if interval <= 0 {
218-
interval = 5 * time.Second
219-
}
148+
func printLoginProblem(out io.Writer, problem string) {
149+
fmt.Fprintf(out, "❌ Problem: %s\n", problem)
150+
}
220151

221-
resp, err := client.Poll(ctx, endpoint, authResp.DeviceCode, interval, authResp.ExpiresIn)
222-
if err != nil {
223-
return nil, err
224-
}
152+
func loginAccessTokenMessage(endpoint string) string {
153+
return fmt.Sprintf("\n"+`🛠 To fix: Create an access token by going to %s/user/settings/tokens, then set the following environment variables in your terminal:
225154
226-
token := resp.Token(endpoint)
227-
token.ClientID = client.ClientID()
228-
return token, nil
229-
}
155+
export SRC_ENDPOINT=%s
156+
export SRC_ACCESS_TOKEN=(your access token)
230157
231-
func openInBrowser(url string) error {
232-
if url == "" {
233-
return nil
234-
}
158+
To verify that it's working, run the login command again.
235159
236-
var cmd *exec.Cmd
237-
switch runtime.GOOS {
238-
case "darwin":
239-
cmd = exec.Command("open", url)
240-
case "windows":
241-
// "start" is a cmd.exe built-in; the empty string is the window title.
242-
cmd = exec.Command("cmd", "/c", "start", "", url)
243-
default:
244-
cmd = exec.Command("xdg-open", url)
245-
}
246-
return cmd.Run()
160+
Alternatively, you can try logging in using OAuth by running: src login --oauth %s
161+
`, endpoint, endpoint, endpoint)
247162
}

cmd/src/login_oauth.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"os/exec"
8+
"runtime"
9+
"time"
10+
11+
"github.com/sourcegraph/src-cli/internal/api"
12+
"github.com/sourcegraph/src-cli/internal/cmderrors"
13+
"github.com/sourcegraph/src-cli/internal/oauth"
14+
)
15+
16+
func runOAuthLogin(ctx context.Context, p loginParams) error {
17+
endpointArg := cleanEndpoint(p.endpoint)
18+
client, err := oauthLoginClient(ctx, p, endpointArg)
19+
if err != nil {
20+
printLoginProblem(p.out, fmt.Sprintf("OAuth Device flow authentication failed: %s", err))
21+
fmt.Fprintln(p.out, loginAccessTokenMessage(endpointArg))
22+
return cmderrors.ExitCode1
23+
}
24+
25+
if err := validateCurrentUser(ctx, client, p.out, endpointArg); err != nil {
26+
return err
27+
}
28+
29+
fmt.Fprintln(p.out)
30+
fmt.Fprint(p.out, "✔︎ Authenticated with OAuth credentials")
31+
fmt.Fprintln(p.out)
32+
return nil
33+
}
34+
35+
func oauthLoginClient(ctx context.Context, p loginParams, endpoint string) (api.Client, error) {
36+
token, err := runOAuthDeviceFlow(ctx, endpoint, p.out, p.oauthClient)
37+
if err != nil {
38+
return nil, err
39+
}
40+
41+
if err := oauth.StoreToken(ctx, token); err != nil {
42+
fmt.Fprintln(p.out)
43+
fmt.Fprintf(p.out, "⚠️ Warning: Failed to store token in keyring store: %q. Continuing with this session only.\n", err)
44+
}
45+
46+
return api.NewClient(api.ClientOpts{
47+
Endpoint: p.cfg.Endpoint,
48+
AdditionalHeaders: p.cfg.AdditionalHeaders,
49+
Flags: p.apiFlags,
50+
Out: p.out,
51+
ProxyURL: p.cfg.ProxyURL,
52+
ProxyPath: p.cfg.ProxyPath,
53+
OAuthToken: token,
54+
}), nil
55+
}
56+
57+
func runOAuthDeviceFlow(ctx context.Context, endpoint string, out io.Writer, client oauth.Client) (*oauth.Token, error) {
58+
authResp, err := client.Start(ctx, endpoint, nil)
59+
if err != nil {
60+
return nil, err
61+
}
62+
63+
authURL := authResp.VerificationURIComplete
64+
msg := fmt.Sprintf("If your browser did not open automatically, visit %s.", authURL)
65+
if authURL == "" {
66+
authURL = authResp.VerificationURI
67+
msg = fmt.Sprintf("If your browser did not open automatically, visit %s and enter the user code %s", authURL, authResp.DeviceCode)
68+
}
69+
_ = openInBrowser(authURL)
70+
fmt.Fprintln(out)
71+
fmt.Fprint(out, msg)
72+
73+
fmt.Fprintln(out)
74+
fmt.Fprint(out, "Waiting for authorization... ")
75+
defer fmt.Fprintf(out, "DONE\n\n")
76+
77+
interval := time.Duration(authResp.Interval) * time.Second
78+
if interval <= 0 {
79+
interval = 5 * time.Second
80+
}
81+
82+
resp, err := client.Poll(ctx, endpoint, authResp.DeviceCode, interval, authResp.ExpiresIn)
83+
if err != nil {
84+
return nil, err
85+
}
86+
87+
token := resp.Token(endpoint)
88+
token.ClientID = client.ClientID()
89+
return token, nil
90+
}
91+
92+
func openInBrowser(url string) error {
93+
if url == "" {
94+
return nil
95+
}
96+
97+
var cmd *exec.Cmd
98+
switch runtime.GOOS {
99+
case "darwin":
100+
cmd = exec.Command("open", url)
101+
case "windows":
102+
// "start" is a cmd.exe built-in; the empty string is the window title.
103+
cmd = exec.Command("cmd", "/c", "start", "", url)
104+
default:
105+
cmd = exec.Command("xdg-open", url)
106+
}
107+
return cmd.Run()
108+
}

0 commit comments

Comments
 (0)