Skip to content
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
132 changes: 126 additions & 6 deletions cmd/src/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,22 @@ import (
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"
"time"

"github.com/sourcegraph/src-cli/internal/api"
"github.com/sourcegraph/src-cli/internal/cmderrors"
"github.com/sourcegraph/src-cli/internal/oauth"
)

func init() {
usage := `'src login' helps you authenticate 'src' to access a Sourcegraph instance with your user credentials.

Usage:

src login SOURCEGRAPH_URL
src login [flags] SOURCEGRAPH_URL

Examples:

Expand All @@ -28,6 +32,15 @@ Examples:
Authenticate to Sourcegraph.com:

$ src login https://sourcegraph.com

Use OAuth device flow to authenticate:

$ src login --oauth https://sourcegraph.com


Override the default client id used during device flow when authenticating:

$ src login --oauth https://sourcegraph.com
`

flagSet := flag.NewFlagSet("login", flag.ExitOnError)
Expand All @@ -38,6 +51,7 @@ Examples:

var (
apiFlags = api.NewFlags(flagSet)
useOAuth = flagSet.Bool("oauth", false, "Use OAuth device flow to obtain an access token interactively")
)

handler := func(args []string) error {
Expand All @@ -54,7 +68,15 @@ Examples:

client := cfg.apiClient(apiFlags, io.Discard)

return loginCmd(context.Background(), cfg, client, endpoint, os.Stdout)
return loginCmd(context.Background(), loginParams{
cfg: cfg,
client: client,
endpoint: endpoint,
out: os.Stdout,
useOAuth: *useOAuth,
apiFlags: apiFlags,
deviceFlowClient: oauth.NewClient(oauth.DefaultClientID),
})
}

commands = append(commands, &command{
Expand All @@ -64,8 +86,21 @@ Examples:
})
}

func loginCmd(ctx context.Context, cfg *config, client api.Client, endpointArg string, out io.Writer) error {
endpointArg = cleanEndpoint(endpointArg)
type loginParams struct {
cfg *config
client api.Client
endpoint string
out io.Writer
useOAuth bool
apiFlags *api.Flags
deviceFlowClient oauth.Client
}

func loginCmd(ctx context.Context, p loginParams) error {
endpointArg := cleanEndpoint(p.endpoint)
cfg := p.cfg
client := p.client
out := p.out

printProblem := func(problem string) {
fmt.Fprintf(out, "❌ Problem: %s\n", problem)
Expand All @@ -77,7 +112,9 @@ func loginCmd(ctx context.Context, cfg *config, client api.Client, endpointArg s
export SRC_ACCESS_TOKEN=(your access token)

To verify that it's working, run the login command again.
`, endpointArg, endpointArg)

Alternatively, you can try logging in using OAuth by running: src login --oauth %s
`, endpointArg, endpointArg, endpointArg)

if cfg.ConfigFilePath != "" {
fmt.Fprintln(out)
Expand All @@ -86,7 +123,7 @@ func loginCmd(ctx context.Context, cfg *config, client api.Client, endpointArg s

noToken := cfg.AccessToken == ""
endpointConflict := endpointArg != cfg.Endpoint
if noToken || endpointConflict {
if !p.useOAuth && (noToken || endpointConflict) {
fmt.Fprintln(out)
switch {
case noToken:
Expand All @@ -98,6 +135,30 @@ func loginCmd(ctx context.Context, cfg *config, client api.Client, endpointArg s
return cmderrors.ExitCode1
}

if p.useOAuth {
token, err := runOAuthDeviceFlow(ctx, endpointArg, out, p.deviceFlowClient)
if err != nil {
printProblem(fmt.Sprintf("OAuth Device flow authentication failed: %s", err))
fmt.Fprintln(out, createAccessTokenMessage)
return cmderrors.ExitCode1
}

if err := oauth.StoreToken(ctx, token); err != nil {
fmt.Fprintln(out)
fmt.Fprintf(out, "⚠️ Warning: Failed to store token in keyring store: %q. Continuing with this session only.\n", err)
}

client = api.NewClient(api.ClientOpts{
Endpoint: cfg.Endpoint,
AdditionalHeaders: cfg.AdditionalHeaders,
Flags: p.apiFlags,
Out: out,
ProxyURL: cfg.ProxyURL,
ProxyPath: cfg.ProxyPath,
OAuthToken: token,
})
}

// See if the user is already authenticated.
query := `query CurrentUser { currentUser { username } }`
var result struct {
Expand All @@ -122,6 +183,65 @@ func loginCmd(ctx context.Context, cfg *config, client api.Client, endpointArg s
}
fmt.Fprintln(out)
fmt.Fprintf(out, "✔️ Authenticated as %s on %s\n", result.CurrentUser.Username, endpointArg)

if p.useOAuth {
fmt.Fprintln(out)
fmt.Fprintf(out, "Authenticated with OAuth credentials")
}

fmt.Fprintln(out)
return nil
}

func runOAuthDeviceFlow(ctx context.Context, endpoint string, out io.Writer, client oauth.Client) (*oauth.Token, error) {
authResp, err := client.Start(ctx, endpoint, nil)
if err != nil {
return nil, err
}

authURL := authResp.VerificationURIComplete
msg := fmt.Sprintf("If your browser did not open automatically, visit %s.", authURL)
if authURL == "" {
authURL = authResp.VerificationURI
msg = fmt.Sprintf("If your browser did not open automatically, visit %s and enter the user code %s", authURL, authResp.DeviceCode)
}
_ = openInBrowser(authURL)
fmt.Fprintln(out)
fmt.Fprint(out, msg)

fmt.Fprintln(out)
fmt.Fprint(out, "Waiting for authorization...")
defer fmt.Fprintf(out, "DONE\n\n")

interval := time.Duration(authResp.Interval) * time.Second
if interval <= 0 {
interval = 5 * time.Second
}

resp, err := client.Poll(ctx, endpoint, authResp.DeviceCode, interval, authResp.ExpiresIn)
if err != nil {
return nil, err
}

token := resp.Token(endpoint)
token.ClientID = client.ClientID()
return token, nil
}

func openInBrowser(url string) error {
if url == "" {
return nil
}

var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
// "start" is a cmd.exe built-in; the empty string is the window title.
cmd = exec.Command("cmd", "/c", "start", "", url)
default:
cmd = exec.Command("xdg-open", url)
}
return cmd.Run()
}
17 changes: 12 additions & 5 deletions cmd/src/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,21 @@ import (
"testing"

"github.com/sourcegraph/src-cli/internal/cmderrors"
"github.com/sourcegraph/src-cli/internal/oauth"
)

func TestLogin(t *testing.T) {
check := func(t *testing.T, cfg *config, endpointArg string) (output string, err error) {
t.Helper()

var out bytes.Buffer
err = loginCmd(context.Background(), cfg, cfg.apiClient(nil, io.Discard), endpointArg, &out)
err = loginCmd(context.Background(), loginParams{
cfg: cfg,
client: cfg.apiClient(nil, io.Discard),
endpoint: endpointArg,
out: &out,
deviceFlowClient: oauth.NewClient(oauth.DefaultClientID),
})
return strings.TrimSpace(out.String()), err
}

Expand All @@ -27,7 +34,7 @@ func TestLogin(t *testing.T) {
if err != cmderrors.ExitCode1 {
t.Fatal(err)
}
wantOut := "❌ Problem: No access token is configured.\n\n🛠 To fix: Create an access token by going to https://sourcegraph.example.com/user/settings/tokens, then set the following environment variables in your terminal:\n\n export SRC_ENDPOINT=https://sourcegraph.example.com\n export SRC_ACCESS_TOKEN=(your access token)\n\n To verify that it's working, run the login command again."
wantOut := "❌ Problem: No access token is configured.\n\n🛠 To fix: Create an access token by going to https://sourcegraph.example.com/user/settings/tokens, then set the following environment variables in your terminal:\n\n export SRC_ENDPOINT=https://sourcegraph.example.com\n export SRC_ACCESS_TOKEN=(your access token)\n\n To verify that it's working, run the login command again.\n\n Alternatively, you can try logging in using OAuth by running: src login --oauth https://sourcegraph.example.com"
if out != wantOut {
t.Errorf("got output %q, want %q", out, wantOut)
}
Expand All @@ -38,7 +45,7 @@ func TestLogin(t *testing.T) {
if err != cmderrors.ExitCode1 {
t.Fatal(err)
}
wantOut := "❌ Problem: No access token is configured.\n\n🛠 To fix: Create an access token by going to https://sourcegraph.example.com/user/settings/tokens, then set the following environment variables in your terminal:\n\n export SRC_ENDPOINT=https://sourcegraph.example.com\n export SRC_ACCESS_TOKEN=(your access token)\n\n To verify that it's working, run the login command again."
wantOut := "❌ Problem: No access token is configured.\n\n🛠 To fix: Create an access token by going to https://sourcegraph.example.com/user/settings/tokens, then set the following environment variables in your terminal:\n\n export SRC_ENDPOINT=https://sourcegraph.example.com\n export SRC_ACCESS_TOKEN=(your access token)\n\n To verify that it's working, run the login command again.\n\n Alternatively, you can try logging in using OAuth by running: src login --oauth https://sourcegraph.example.com"
if out != wantOut {
t.Errorf("got output %q, want %q", out, wantOut)
}
Expand All @@ -49,7 +56,7 @@ func TestLogin(t *testing.T) {
if err != cmderrors.ExitCode1 {
t.Fatal(err)
}
wantOut := "⚠️ 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 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 by going to https://example.com/user/settings/tokens, then set the following environment variables in your terminal:\n\n export SRC_ENDPOINT=https://example.com\n export SRC_ACCESS_TOKEN=(your access token)\n\n To verify that it's working, run the login command again."
wantOut := "⚠️ 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 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 by going to https://example.com/user/settings/tokens, then set the following environment variables in your terminal:\n\n export SRC_ENDPOINT=https://example.com\n export SRC_ACCESS_TOKEN=(your access token)\n\n To verify that it's working, run the login command again.\n\n Alternatively, you can try logging in using OAuth by running: src login --oauth https://example.com"
if out != wantOut {
t.Errorf("got output %q, want %q", out, wantOut)
}
Expand All @@ -67,7 +74,7 @@ func TestLogin(t *testing.T) {
if err != cmderrors.ExitCode1 {
t.Fatal(err)
}
wantOut := "❌ Problem: Invalid access token.\n\n🛠 To fix: Create an access token by going to $ENDPOINT/user/settings/tokens, then set the following environment variables in your terminal:\n\n export SRC_ENDPOINT=$ENDPOINT\n export SRC_ACCESS_TOKEN=(your access token)\n\n To verify that it's working, run the login command again.\n\n (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)"
wantOut := "❌ Problem: Invalid access token.\n\n🛠 To fix: Create an access token by going to $ENDPOINT/user/settings/tokens, then set the following environment variables in your terminal:\n\n export SRC_ENDPOINT=$ENDPOINT\n export SRC_ACCESS_TOKEN=(your access token)\n\n To verify that it's working, run the login command again.\n\n Alternatively, you can try logging in using OAuth by running: src login --oauth $ENDPOINT\n\n (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)"
wantOut = strings.ReplaceAll(wantOut, "$ENDPOINT", endpoint)
if out != wantOut {
t.Errorf("got output %q, want %q", out, wantOut)
Expand Down
15 changes: 13 additions & 2 deletions cmd/src/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"encoding/json"
"flag"
"io"
Expand All @@ -15,6 +16,7 @@ import (
"github.com/sourcegraph/sourcegraph/lib/errors"

"github.com/sourcegraph/src-cli/internal/api"
"github.com/sourcegraph/src-cli/internal/oauth"
)

const usageText = `src is a tool that provides access to Sourcegraph instances.
Expand Down Expand Up @@ -122,15 +124,24 @@ type config struct {

// apiClient returns an api.Client built from the configuration.
func (c *config) apiClient(flags *api.Flags, out io.Writer) api.Client {
return api.NewClient(api.ClientOpts{
opts := api.ClientOpts{
Endpoint: c.Endpoint,
AccessToken: c.AccessToken,
AdditionalHeaders: c.AdditionalHeaders,
Flags: flags,
Out: out,
ProxyURL: c.ProxyURL,
ProxyPath: c.ProxyPath,
})
}

// Only use OAuth if we do not have SRC_ACCESS_TOKEN set
if c.AccessToken == "" {
if t, err := oauth.LoadToken(context.Background(), c.Endpoint); err == nil {
opts.OAuthToken = t
}
}

return api.NewClient(opts)
}

// readConfig reads the config file from the given path.
Expand Down
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ require (
github.com/sourcegraph/sourcegraph/lib v0.0.0-20240709083501-1af563b61442
github.com/stretchr/testify v1.11.1
github.com/tliron/glsp v0.2.2
github.com/zalando/go-keyring v0.2.6
golang.org/x/sync v0.18.0
google.golang.org/api v0.256.0
google.golang.org/protobuf v1.36.10
Expand All @@ -41,6 +42,7 @@ require (
)

require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect
cel.dev/expr v0.24.0 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
Expand All @@ -64,6 +66,7 @@ require (
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.14.3 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v24.0.4+incompatible // indirect
github.com/docker/distribution v2.8.2+incompatible // indirect
Expand All @@ -78,6 +81,7 @@ require (
github.com/go-chi/chi/v5 v5.2.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gofrs/uuid/v5 v5.0.0 // indirect
github.com/google/gnostic-models v0.6.9 // indirect
github.com/google/go-containerregistry v0.19.1 // indirect
Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
Expand Down Expand Up @@ -139,6 +141,8 @@ github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglD
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
Expand Down Expand Up @@ -212,6 +216,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gofrs/uuid/v5 v5.0.0 h1:p544++a97kEL+svbcFbCQVM9KFu0Yo25UoISXGNNH9M=
Expand Down Expand Up @@ -243,6 +249,8 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
Expand Down Expand Up @@ -495,6 +503,8 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
Expand Down
Loading
Loading