From 0c74525f112c41537a0081ddb3ebb90edc333ff5 Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Thu, 4 Dec 2025 11:46:44 +0200 Subject: [PATCH 01/10] use keyring to store oauth token From c78d051a3f5be9280a82761805d56d6cc8603af9 Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Thu, 4 Dec 2025 11:53:35 +0200 Subject: [PATCH 02/10] add internal/keyring package to use 99designs keyring - rename keyring to store - make keyring struct src-cli and set label on secret --- go.mod | 7 ++++ go.sum | 14 ++++++++ internal/keyring/keyring.go | 66 +++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 internal/keyring/keyring.go diff --git a/go.mod b/go.mod index 3c9e3eb338..2cce32f1dd 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,8 @@ require ( cloud.google.com/go/auth v0.17.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect + github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/99designs/keyring v1.2.2 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 // indirect @@ -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.0 // 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 @@ -71,6 +74,7 @@ require ( github.com/docker/docker-credential-helpers v0.8.0 // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/dvsekhvalnov/jose2go v1.5.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/felixge/fgprof v0.9.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -78,6 +82,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 v0.0.0-20190726142602-4481cbc300e2 // 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 @@ -85,6 +90,7 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/iancoleman/strcase v0.3.0 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgconn v1.14.3 // indirect @@ -95,6 +101,7 @@ require ( github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/mtibben/percent v0.2.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc4 // indirect github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect diff --git a/go.sum b/go.sum index f47d1d10c9..be3b08291b 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,10 @@ cloud.google.com/go/storage v1.50.0 h1:3TbVkzTooBvnZsk7WaAQfOsNrdoM8QHusXA1cpk6Q cloud.google.com/go/storage v1.50.0/go.mod h1:l7XeiD//vx5lfqE3RavfmU9yvk5Pp0Zhcv482poyafY= cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= +github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= @@ -139,6 +143,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.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= +github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= 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= @@ -165,6 +171,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM= +github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= @@ -212,6 +220,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 v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= 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= @@ -258,6 +268,8 @@ github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7E github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/hexops/autogold v0.8.1/go.mod h1:97HLDXyG23akzAoRYJh/2OBs3kd80eHyKPvZw0S5ZBY= github.com/hexops/autogold v1.3.1 h1:YgxF9OHWbEIUjhDbpnLhgVsjUDsiHDTyDfy2lrfdlzo= github.com/hexops/autogold v1.3.1/go.mod h1:sQO+mQUCVfxOKPht+ipDSkJ2SCJ7BNJVHZexsXqWMx4= @@ -361,6 +373,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go new file mode 100644 index 0000000000..b86eddc123 --- /dev/null +++ b/internal/keyring/keyring.go @@ -0,0 +1,66 @@ +// Package keyring provides secure credential storage using the system keychain. +package keyring + +import ( + "github.com/99designs/keyring" + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +const ( + serviceName = "sourcegraph-cli" + + KeyOAuth = "oauth" +) + +// Store provides secure credential storage operations. +type Store struct { + ring keyring.Keyring +} + +// Open opens the system keyring for the Sourcegraph CLI. +func Open() (*Store, error) { + ring, err := keyring.Open(keyring.Config{ + ServiceName: serviceName, + KeychainName: "login", // This is the default name for the keychain where MacOS puts all login passwords + KeychainTrustApplication: true, // the keychain can trust src-cli! + }) + if err != nil { + return nil, errors.Wrap(err, "opening keyring") + } + return &Store{ring: ring}, nil +} + +// Set stores a key-value pair in the keyring. +func (s *Store) Set(key string, data []byte) error { + err := s.ring.Set(keyring.Item{ + Key: key, + Data: data, + Label: key, + }) + if err != nil { + return errors.Wrap(err, "storing item in keyring") + } + return nil +} + +// Get retrieves a value by key from the keyring. +// Returns nil, nil if the key is not found. +func (s *Store) Get(key string) ([]byte, error) { + item, err := s.ring.Get(key) + if err != nil { + if err == keyring.ErrKeyNotFound { + return nil, nil + } + return nil, errors.Wrap(err, "getting item from keyring") + } + return item.Data, nil +} + +// Delete removes a key from the keyring. +func (s *Store) Delete(key string) error { + err := s.ring.Remove(key) + if err != nil && err != keyring.ErrKeyNotFound { + return errors.Wrap(err, "removing item from keyring") + } + return nil +} From fbb6b2d06753d2cd38b44fa989746439a75a289b Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Thu, 4 Dec 2025 14:21:58 +0200 Subject: [PATCH 03/10] return Token struct --- internal/oauthdevice/device_flow.go | 14 +++++++----- internal/oauthdevice/device_flow_test.go | 28 ++++++++++++++---------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/internal/oauthdevice/device_flow.go b/internal/oauthdevice/device_flow.go index b13f60af70..6868a1cade 100644 --- a/internal/oauthdevice/device_flow.go +++ b/internal/oauthdevice/device_flow.go @@ -50,13 +50,16 @@ type DeviceAuthResponse struct { ExpiresIn int `json:"expires_in"` Interval int `json:"interval"` } - -type TokenResponse struct { +type Token struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token,omitempty"` - TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in,omitempty"` - Scope string `json:"scope,omitempty"` +} + +type TokenResponse struct { + Token + TokenType string `json:"token_type"` + Scope string `json:"scope,omitempty"` } type ErrorResponse struct { @@ -359,4 +362,5 @@ func (c *httpClient) Refresh(ctx context.Context, endpoint, refreshToken string) } return &tokenResp, nil -} \ No newline at end of file +} + diff --git a/internal/oauthdevice/device_flow_test.go b/internal/oauthdevice/device_flow_test.go index db7fedf38c..3297ec4e36 100644 --- a/internal/oauthdevice/device_flow_test.go +++ b/internal/oauthdevice/device_flow_test.go @@ -266,10 +266,12 @@ func TestStart_NoDeviceEndpoint(t *testing.T) { func TestPoll_Success(t *testing.T) { wantToken := TokenResponse{ - AccessToken: "test-access-token", - TokenType: "Bearer", - ExpiresIn: 3600, - Scope: "read write", + Token: Token{ + AccessToken: "test-access-token", + ExpiresIn: 3600, + }, + Scope: "read write", + TokenType: "Bearer", } server := newTestServer(t, testServerOptions{ @@ -335,8 +337,8 @@ func TestPoll_AuthorizationPending(t *testing.T) { } json.NewEncoder(w).Encode(TokenResponse{ - AccessToken: "test-access-token", - TokenType: "Bearer", + Token: Token{AccessToken: "test-access-token"}, + TokenType: "Bearer", }) }, }, @@ -377,8 +379,8 @@ func TestPoll_SlowDown(t *testing.T) { } json.NewEncoder(w).Encode(TokenResponse{ - AccessToken: "test-access-token", - TokenType: "Bearer", + Token: Token{AccessToken: "test-access-token"}, + TokenType: "Bearer", }) }, }, @@ -525,10 +527,12 @@ func TestRefresh_Success(t *testing.T) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(TokenResponse{ - AccessToken: "new-access-token", - RefreshToken: "new-refresh-token", - TokenType: "Bearer", - ExpiresIn: 3600, + Token: Token{ + AccessToken: "new-access-token", + RefreshToken: "new-refresh-token", + ExpiresIn: 3600, + }, + TokenType: "Bearer", }) }, }, From 72de6bd775ccc9b52895cee8b4b32d86c439346d Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Thu, 4 Dec 2025 14:22:10 +0200 Subject: [PATCH 04/10] store token in keyring --- cmd/src/login.go | 31 ++++++++++++++++++++++------- internal/keyring/keyring.go | 6 +----- internal/oauthdevice/device_flow.go | 28 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/cmd/src/login.go b/cmd/src/login.go index e42632c3b0..51bff777be 100644 --- a/cmd/src/login.go +++ b/cmd/src/login.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "io" @@ -11,7 +12,10 @@ import ( "github.com/sourcegraph/src-cli/internal/api" "github.com/sourcegraph/src-cli/internal/cmderrors" + "github.com/sourcegraph/src-cli/internal/keyring" "github.com/sourcegraph/src-cli/internal/oauthdevice" + + "github.com/sourcegraph/sourcegraph/lib/errors" ) func init() { @@ -125,16 +129,26 @@ func loginCmd(ctx context.Context, p loginParams) error { noToken := cfg.AccessToken == "" endpointConflict := endpointArg != cfg.Endpoint + secretStore, err := keyring.Open() + if err != nil { + printProblem(fmt.Sprintf("could not open keyring for secret storage: %s", err)) + } + + cfg.Endpoint = endpointArg + if p.useDeviceFlow { - token, err := runDeviceFlow(ctx, endpointArg, out, p.deviceFlowClient) + resp, err := runDeviceFlow(ctx, endpointArg, out, p.deviceFlowClient) if err != nil { printProblem(fmt.Sprintf("Device flow authentication failed: %s", err)) fmt.Fprintln(out, createAccessTokenMessage) return cmderrors.ExitCode1 } - cfg.AccessToken = token - cfg.Endpoint = endpointArg + if err := oauthdevice.StoreToken(secretStore, &resp.Token); err != nil { + printProblem(fmt.Sprintf("Failed to store token in keyring store: %s", err)) + return cmderrors.ExitCode1 + } + client = cfg.apiClient(p.apiFlags, out) } else if noToken || endpointConflict { fmt.Fprintln(out) @@ -184,10 +198,13 @@ func loginCmd(ctx context.Context, p loginParams) error { return nil } -func runDeviceFlow(ctx context.Context, endpoint string, out io.Writer, client oauthdevice.Client) (string, error) { +func storeToken(store *keyring.Store, token *oauthdevice.Token) error { +} + +func runDeviceFlow(ctx context.Context, endpoint string, out io.Writer, client oauthdevice.Client) (*oauthdevice.TokenResponse, error) { authResp, err := client.Start(ctx, endpoint, nil) if err != nil { - return "", err + return nil, err } fmt.Fprintln(out) @@ -207,8 +224,8 @@ func runDeviceFlow(ctx context.Context, endpoint string, out io.Writer, client o tokenResp, err := client.Poll(ctx, endpoint, authResp.DeviceCode, interval, authResp.ExpiresIn) if err != nil { - return "", err + return nil, err } - return tokenResp.AccessToken, nil + return tokenResp, nil } diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go index b86eddc123..47b18e03bb 100644 --- a/internal/keyring/keyring.go +++ b/internal/keyring/keyring.go @@ -6,11 +6,7 @@ import ( "github.com/sourcegraph/sourcegraph/lib/errors" ) -const ( - serviceName = "sourcegraph-cli" - - KeyOAuth = "oauth" -) +const serviceName = "sourcegraph-cli" // Store provides secure credential storage operations. type Store struct { diff --git a/internal/oauthdevice/device_flow.go b/internal/oauthdevice/device_flow.go index 6868a1cade..73fbff1b01 100644 --- a/internal/oauthdevice/device_flow.go +++ b/internal/oauthdevice/device_flow.go @@ -13,6 +13,8 @@ import ( "testing" "time" + "github.com/sourcegraph/src-cli/internal/keyring" + "github.com/sourcegraph/sourcegraph/lib/errors" ) @@ -23,6 +25,9 @@ const ( // wellKnownPath is the path on the sourcegraph server where clients can discover OAuth configuration wellKnownPath = "/.well-known/openid-configuration" + // Key used to store the token in the store + KeyOAuth = "oauth" + GrantTypeDeviceCode string = "urn:ietf:params:oauth:grant-type:device_code" ScopeOpenID string = "openid" @@ -364,3 +369,26 @@ func (c *httpClient) Refresh(ctx context.Context, endpoint, refreshToken string) return &tokenResp, nil } +func StoreToken(store *keyring.Store, token *Token) error { + data, err := json.Marshal(token) + if err != nil { + return errors.Wrap(err, "failed to marshal token") + } + + // TODO(burmudar): do we need a suffix that is the endpoint? ex. oauth-sourcegraph.com + return store.Set(KeyOAuth, data) +} + +func LoadToken(store *keyring.Store) (*Token, error) { + var t Token + data, err := store.Get(KeyOAuth) + if err != nil { + return nil, errors.Wrap(err, "failed to get token from store") + } + + if err := json.Unmarshal(data, &t); err != nil { + return nil, errors.Wrap(err, "failed to unmarshall token") + } + + return &t, nil +} From 931e5492fdca2a0770f979422e6c982e8b5ae2f0 Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Thu, 4 Dec 2025 15:53:20 +0200 Subject: [PATCH 05/10] create token struct from TokenResponse - Token converts expiresIn to a timestamp - Store the token with the endpoint suffix --- internal/oauthdevice/device_flow.go | 54 +++++++++++++++--------- internal/oauthdevice/device_flow_test.go | 37 ++++++++-------- 2 files changed, 55 insertions(+), 36 deletions(-) diff --git a/internal/oauthdevice/device_flow.go b/internal/oauthdevice/device_flow.go index 73fbff1b01..0fa13de938 100644 --- a/internal/oauthdevice/device_flow.go +++ b/internal/oauthdevice/device_flow.go @@ -26,7 +26,7 @@ const ( wellKnownPath = "/.well-known/openid-configuration" // Key used to store the token in the store - KeyOAuth = "oauth" + KeyOAuth = "Sourcegraph CLI key storage" GrantTypeDeviceCode string = "urn:ietf:params:oauth:grant-type:device_code" @@ -55,16 +55,20 @@ type DeviceAuthResponse struct { ExpiresIn int `json:"expires_in"` Interval int `json:"interval"` } -type Token struct { + +type TokenResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token,omitempty"` ExpiresIn int `json:"expires_in,omitempty"` + TokenType string `json:"token_type"` + Scope string `json:"scope,omitempty"` } -type TokenResponse struct { - Token - TokenType string `json:"token_type"` - Scope string `json:"scope,omitempty"` +type Token struct { + Endpoint string `json:"endpoint"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresAt time.Time `json:"expires_at"` } type ErrorResponse struct { @@ -76,7 +80,7 @@ type Client interface { Discover(ctx context.Context, endpoint string) (*OIDCConfiguration, error) Start(ctx context.Context, endpoint string, scopes []string) (*DeviceAuthResponse, error) Poll(ctx context.Context, endpoint, deviceCode string, interval time.Duration, expiresIn int) (*TokenResponse, error) - Refresh(ctx context.Context, endpoint, refreshToken string) (*TokenResponse, error) + Refresh(ctx context.Context, token *Token) (*TokenResponse, error) } type httpClient struct { @@ -318,22 +322,20 @@ func (c *httpClient) pollOnce(ctx context.Context, tokenEndpoint, deviceCode str } // Refresh exchanges a refresh token for a new access token. -func (c *httpClient) Refresh(ctx context.Context, endpoint, refreshToken string) (*TokenResponse, error) { - endpoint = strings.TrimRight(endpoint, "/") - - config, err := c.Discover(ctx, endpoint) +func (c *httpClient) Refresh(ctx context.Context, token *Token) (*TokenResponse, error) { + config, err := c.Discover(ctx, token.Endpoint) if err != nil { - return nil, errors.Wrap(err, "OIDC discovery failed") + errors.Wrap(err, "failed to discover OIDC configuration") } if config.TokenEndpoint == "" { - return nil, errors.New("token endpoint not found in OIDC configuration") + errors.New("OIDC configuration has no token endpoint") } data := url.Values{} data.Set("client_id", c.clientID) data.Set("grant_type", "refresh_token") - data.Set("refresh_token", refreshToken) + data.Set("refresh_token", token.RefreshToken) req, err := http.NewRequestWithContext(ctx, "POST", config.TokenEndpoint, strings.NewReader(data.Encode())) if err != nil { @@ -366,7 +368,16 @@ func (c *httpClient) Refresh(ctx context.Context, endpoint, refreshToken string) return nil, errors.Wrap(err, "parsing refresh token response") } - return &tokenResp, nil + return &tokenResp, err +} + +func (t *TokenResponse) Token(endpoint string) *Token { + return &Token{ + Endpoint: strings.TrimRight(endpoint, "/"), + RefreshToken: t.RefreshToken, + AccessToken: t.AccessToken, + ExpiresAt: time.Now().Add(time.Second * time.Duration(t.ExpiresIn)), + } } func StoreToken(store *keyring.Store, token *Token) error { @@ -375,13 +386,18 @@ func StoreToken(store *keyring.Store, token *Token) error { return errors.Wrap(err, "failed to marshal token") } - // TODO(burmudar): do we need a suffix that is the endpoint? ex. oauth-sourcegraph.com - return store.Set(KeyOAuth, data) + if token.Endpoint == "" { + return errors.New("token endpoint cannot be empty when storing the token") + } + + key := fmt.Sprintf("%s <%s>", KeyOAuth, token.Endpoint) + return store.Set(key, data) } -func LoadToken(store *keyring.Store) (*Token, error) { +func LoadToken(store *keyring.Store, endpoint string) (*Token, error) { + key := fmt.Sprintf("%s <%s>", KeyOAuth, endpoint) var t Token - data, err := store.Get(KeyOAuth) + data, err := store.Get(key) if err != nil { return nil, errors.Wrap(err, "failed to get token from store") } diff --git a/internal/oauthdevice/device_flow_test.go b/internal/oauthdevice/device_flow_test.go index 3297ec4e36..1b958253db 100644 --- a/internal/oauthdevice/device_flow_test.go +++ b/internal/oauthdevice/device_flow_test.go @@ -266,12 +266,10 @@ func TestStart_NoDeviceEndpoint(t *testing.T) { func TestPoll_Success(t *testing.T) { wantToken := TokenResponse{ - Token: Token{ - AccessToken: "test-access-token", - ExpiresIn: 3600, - }, - Scope: "read write", - TokenType: "Bearer", + AccessToken: "test-access-token", + ExpiresIn: 3600, + Scope: "read write", + TokenType: "Bearer", } server := newTestServer(t, testServerOptions{ @@ -315,6 +313,7 @@ func TestPoll_Success(t *testing.T) { if resp.TokenType != wantToken.TokenType { t.Errorf("TokenType = %q, want %q", resp.TokenType, wantToken.TokenType) } + } func TestPoll_AuthorizationPending(t *testing.T) { @@ -337,8 +336,8 @@ func TestPoll_AuthorizationPending(t *testing.T) { } json.NewEncoder(w).Encode(TokenResponse{ - Token: Token{AccessToken: "test-access-token"}, - TokenType: "Bearer", + AccessToken: "test-access-token", + TokenType: "Bearer", }) }, }, @@ -379,8 +378,8 @@ func TestPoll_SlowDown(t *testing.T) { } json.NewEncoder(w).Encode(TokenResponse{ - Token: Token{AccessToken: "test-access-token"}, - TokenType: "Bearer", + AccessToken: "test-access-token", + TokenType: "Bearer", }) }, }, @@ -527,12 +526,10 @@ func TestRefresh_Success(t *testing.T) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(TokenResponse{ - Token: Token{ - AccessToken: "new-access-token", - RefreshToken: "new-refresh-token", - ExpiresIn: 3600, - }, - TokenType: "Bearer", + AccessToken: "new-access-token", + RefreshToken: "new-refresh-token", + ExpiresIn: 3600, + TokenType: "Bearer", }) }, }, @@ -540,7 +537,13 @@ func TestRefresh_Success(t *testing.T) { defer server.Close() client := NewClient(DefaultClientID) - resp, err := client.Refresh(context.Background(), server.URL, "test-refresh-token") + token := &Token{ + Endpoint: server.URL, + AccessToken: "new-access-token", + RefreshToken: "test-refresh-token", + ExpiresAt: time.Now().Add(time.Second * time.Duration(3600)), + } + resp, err := client.Refresh(context.Background(), token) if err != nil { t.Fatalf("Refresh() error = %v", err) } From b1cbe4248410d5b21921ddf29f0dad7fac6163e1 Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Thu, 4 Dec 2025 17:52:44 +0200 Subject: [PATCH 06/10] add basic http transport for oauth --- cmd/src/login.go | 16 +++------ internal/oauthdevice/device_flow.go | 9 +++++ internal/oauthdevice/http_transport.go | 49 ++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 internal/oauthdevice/http_transport.go diff --git a/cmd/src/login.go b/cmd/src/login.go index 51bff777be..88f010d09a 100644 --- a/cmd/src/login.go +++ b/cmd/src/login.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "flag" "fmt" "io" @@ -14,8 +13,6 @@ import ( "github.com/sourcegraph/src-cli/internal/cmderrors" "github.com/sourcegraph/src-cli/internal/keyring" "github.com/sourcegraph/src-cli/internal/oauthdevice" - - "github.com/sourcegraph/sourcegraph/lib/errors" ) func init() { @@ -137,14 +134,14 @@ func loginCmd(ctx context.Context, p loginParams) error { cfg.Endpoint = endpointArg if p.useDeviceFlow { - resp, err := runDeviceFlow(ctx, endpointArg, out, p.deviceFlowClient) + token, err := runDeviceFlow(ctx, endpointArg, out, p.deviceFlowClient) if err != nil { printProblem(fmt.Sprintf("Device flow authentication failed: %s", err)) fmt.Fprintln(out, createAccessTokenMessage) return cmderrors.ExitCode1 } - if err := oauthdevice.StoreToken(secretStore, &resp.Token); err != nil { + if err := oauthdevice.StoreToken(secretStore, token); err != nil { printProblem(fmt.Sprintf("Failed to store token in keyring store: %s", err)) return cmderrors.ExitCode1 } @@ -198,10 +195,7 @@ func loginCmd(ctx context.Context, p loginParams) error { return nil } -func storeToken(store *keyring.Store, token *oauthdevice.Token) error { -} - -func runDeviceFlow(ctx context.Context, endpoint string, out io.Writer, client oauthdevice.Client) (*oauthdevice.TokenResponse, error) { +func runDeviceFlow(ctx context.Context, endpoint string, out io.Writer, client oauthdevice.Client) (*oauthdevice.Token, error) { authResp, err := client.Start(ctx, endpoint, nil) if err != nil { return nil, err @@ -222,10 +216,10 @@ func runDeviceFlow(ctx context.Context, endpoint string, out io.Writer, client o interval = 5 * time.Second } - tokenResp, err := client.Poll(ctx, endpoint, authResp.DeviceCode, interval, authResp.ExpiresIn) + resp, err := client.Poll(ctx, endpoint, authResp.DeviceCode, interval, authResp.ExpiresIn) if err != nil { return nil, err } - return tokenResp, nil + return resp.Token(endpoint), nil } diff --git a/internal/oauthdevice/device_flow.go b/internal/oauthdevice/device_flow.go index 0fa13de938..8937c7866b 100644 --- a/internal/oauthdevice/device_flow.go +++ b/internal/oauthdevice/device_flow.go @@ -380,6 +380,15 @@ func (t *TokenResponse) Token(endpoint string) *Token { } } +func (t *Token) HasExpired() bool { + return time.Now().After(t.ExpiresAt) +} + +func (t *Token) ExpiringIn(d time.Duration) bool { + future := time.Now().Add(d) + return future.After(t.ExpiresAt) +} + func StoreToken(store *keyring.Store, token *Token) error { data, err := json.Marshal(token) if err != nil { diff --git a/internal/oauthdevice/http_transport.go b/internal/oauthdevice/http_transport.go new file mode 100644 index 0000000000..065a01275b --- /dev/null +++ b/internal/oauthdevice/http_transport.go @@ -0,0 +1,49 @@ +package oauthdevice + +import ( + "context" + "net/http" + "time" +) + +var _ http.Transport + +var _ http.RoundTripper = (*Transport)(nil) + +type Transport struct { + Base http.RoundTripper + token *Token +} + +// RoundTrip implements http.RoundTripper. +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + ctx := req.Context() + token, err := maybeRefresh(ctx, t.token) + if err != nil { + return nil, err + } + t.token = token + + req2 := req.Clone(req.Context()) + req2.Header.Set("Authorization", "Bearer "+t.token.AccessToken) + + if t.Base != nil { + return t.Base.RoundTrip(req2) + } + return http.DefaultTransport.RoundTrip(req2) +} + +func maybeRefresh(ctx context.Context, token *Token) (*Token, error) { + // token has NOT expired or NOT about to expire in 30s + if !(token.HasExpired() || token.ExpiringIn(time.Duration(30)*time.Second)) { + return token, nil + } + client := NewClient(DefaultClientID) + + resp, err := client.Refresh(ctx, token) + if err != nil { + return nil, err + } + + return resp.Token(token.Endpoint), nil +} From 5c8a6d6b706e6b9b3b9f19e8ce22f5ea62198742 Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Fri, 5 Dec 2025 10:25:26 +0200 Subject: [PATCH 07/10] OAuth transport and use when available in api client --- cmd/src/main.go | 16 ++++++++++++++-- internal/api/api.go | 10 ++++++++++ internal/oauthdevice/http_transport.go | 8 ++++---- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/cmd/src/main.go b/cmd/src/main.go index edfb1073d7..7fccd3e396 100644 --- a/cmd/src/main.go +++ b/cmd/src/main.go @@ -15,6 +15,8 @@ import ( "github.com/sourcegraph/sourcegraph/lib/errors" "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/keyring" + "github.com/sourcegraph/src-cli/internal/oauthdevice" ) const usageText = `src is a tool that provides access to Sourcegraph instances. @@ -122,7 +124,7 @@ 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, @@ -130,7 +132,17 @@ func (c *config) apiClient(flags *api.Flags, out io.Writer) api.Client { Out: out, ProxyURL: c.ProxyURL, ProxyPath: c.ProxyPath, - }) + } + store, err := keyring.Open() + if err != nil { + panic("HALP") + } + + if t, err := oauthdevice.LoadToken(store, c.Endpoint); err == nil { + opts.OAuthToken = t + } + + return api.NewClient(opts) } // readConfig reads the config file from the given path. diff --git a/internal/api/api.go b/internal/api/api.go index 5f750c1d4a..1c769f7138 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -18,6 +18,7 @@ import ( "github.com/kballard/go-shellquote" "github.com/mattn/go-isatty" + "github.com/sourcegraph/src-cli/internal/oauthdevice" "github.com/sourcegraph/src-cli/internal/version" ) @@ -85,6 +86,8 @@ type ClientOpts struct { ProxyURL *url.URL ProxyPath string + + OAuthToken *oauthdevice.Token } func buildTransport(opts ClientOpts, flags *Flags) *http.Transport { @@ -102,6 +105,13 @@ func buildTransport(opts ClientOpts, flags *Flags) *http.Transport { transport = withProxyTransport(transport, opts.ProxyURL, opts.ProxyPath) } + if opt.AccessToken == "" && opt.OAuthToken != nil { + transport = &oauthdevice.Transport{ + Base: transport, + Token: opts.OAuthToken + } + } + return transport } diff --git a/internal/oauthdevice/http_transport.go b/internal/oauthdevice/http_transport.go index 065a01275b..483b45108d 100644 --- a/internal/oauthdevice/http_transport.go +++ b/internal/oauthdevice/http_transport.go @@ -12,20 +12,20 @@ var _ http.RoundTripper = (*Transport)(nil) type Transport struct { Base http.RoundTripper - token *Token + Token *Token } // RoundTrip implements http.RoundTripper. func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { ctx := req.Context() - token, err := maybeRefresh(ctx, t.token) + token, err := maybeRefresh(ctx, t.Token) if err != nil { return nil, err } - t.token = token + t.Token = token req2 := req.Clone(req.Context()) - req2.Header.Set("Authorization", "Bearer "+t.token.AccessToken) + req2.Header.Set("Authorization", "Bearer "+t.Token.AccessToken) if t.Base != nil { return t.Base.RoundTrip(req2) From a59d4e7324728c002410b293b337cb94357443fd Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Mon, 8 Dec 2025 17:02:29 +0200 Subject: [PATCH 08/10] add secrets package to manage secrets - Add secret store that supports different backends - We use a registry map for a few secrets and the registry gets persisted as one secret to the keyring. We don't waant to create a keyring secret for every different secret - Store is opened once to load the registry. --- internal/{keyring => secrets}/keyring.go | 27 +++--- internal/secrets/store.go | 104 +++++++++++++++++++++++ 2 files changed, 117 insertions(+), 14 deletions(-) rename internal/{keyring => secrets}/keyring.go (66%) create mode 100644 internal/secrets/store.go diff --git a/internal/keyring/keyring.go b/internal/secrets/keyring.go similarity index 66% rename from internal/keyring/keyring.go rename to internal/secrets/keyring.go index 47b18e03bb..3acd981b18 100644 --- a/internal/keyring/keyring.go +++ b/internal/secrets/keyring.go @@ -1,5 +1,4 @@ -// Package keyring provides secure credential storage using the system keychain. -package keyring +package secrets import ( "github.com/99designs/keyring" @@ -8,13 +7,13 @@ import ( const serviceName = "sourcegraph-cli" -// Store provides secure credential storage operations. -type Store struct { +// keyringStore provides secure credential storage operations. +type keyringStore struct { ring keyring.Keyring } -// Open opens the system keyring for the Sourcegraph CLI. -func Open() (*Store, error) { +// open opens the system keyring for the Sourcegraph CLI. +func openKeyring() (*keyringStore, error) { ring, err := keyring.Open(keyring.Config{ ServiceName: serviceName, KeychainName: "login", // This is the default name for the keychain where MacOS puts all login passwords @@ -23,12 +22,12 @@ func Open() (*Store, error) { if err != nil { return nil, errors.Wrap(err, "opening keyring") } - return &Store{ring: ring}, nil + return &keyringStore{ring: ring}, nil } // Set stores a key-value pair in the keyring. -func (s *Store) Set(key string, data []byte) error { - err := s.ring.Set(keyring.Item{ +func (k *keyringStore) Put(key string, data []byte) error { + err := k.ring.Set(keyring.Item{ Key: key, Data: data, Label: key, @@ -41,11 +40,11 @@ func (s *Store) Set(key string, data []byte) error { // Get retrieves a value by key from the keyring. // Returns nil, nil if the key is not found. -func (s *Store) Get(key string) ([]byte, error) { - item, err := s.ring.Get(key) +func (k *keyringStore) Get(key string) ([]byte, error) { + item, err := k.ring.Get(key) if err != nil { if err == keyring.ErrKeyNotFound { - return nil, nil + return nil, ErrSecretNotFound } return nil, errors.Wrap(err, "getting item from keyring") } @@ -53,8 +52,8 @@ func (s *Store) Get(key string) ([]byte, error) { } // Delete removes a key from the keyring. -func (s *Store) Delete(key string) error { - err := s.ring.Remove(key) +func (k *keyringStore) Delete(key string) error { + err := k.ring.Remove(key) if err != nil && err != keyring.ErrKeyNotFound { return errors.Wrap(err, "removing item from keyring") } diff --git a/internal/secrets/store.go b/internal/secrets/store.go new file mode 100644 index 0000000000..dda306859e --- /dev/null +++ b/internal/secrets/store.go @@ -0,0 +1,104 @@ +package secrets + +import ( + "encoding/json" + "sync" + + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +const keyRegistry = "secret-registry" + +var ErrSecretNotFound = errors.New("secret not found") + +var openOnce = sync.OnceValues(Open) + +type SecretStorage interface { + Get(key string) ([]byte, error) + Put(key string, data []byte) error + Delete(key string) error +} + +type store struct { + backend SecretStorage + registry map[string][]byte + + mu sync.Mutex +} + +func Store() (SecretStorage, error) { + return openOnce() +} + +func Open() (SecretStorage, error) { + keyring, err := openKeyring() + if err != nil { + return nil, err + } + + registry, err := getRegistry(keyring) + if err != nil { + return nil, err + } + s := &store{ + backend: keyring, + registry: registry, + } + + return s, nil +} + +func getRegistry(s SecretStorage) (map[string][]byte, error) { + data, err := s.Get(keyRegistry) + if err != nil { + return nil, errors.Wrap(err, "failed to load registry from backing store") + } + + var registry map[string][]byte + if err := json.Unmarshal(data, ®istry); err != nil { + return nil, errors.Wrap(err, "failed to decode registry from backing store") + } + + return registry, nil +} + +func saveRegistry(s SecretStorage, registry map[string][]byte) error { + data, err := json.Marshal(®istry) + if err != nil { + return errors.Wrap(err, "registry encoding failure") + } + + if err = s.Put(keyRegistry, data); err != nil { + return errors.Wrap(err, "failed to persist registry to backing store") + } + + return nil +} + +func (s *store) Get(key string) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.registry[key] + if !ok { + return nil, ErrSecretNotFound + } + + return v, nil +} + +func (s *store) Put(key string, data []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.registry[key] = data + + return saveRegistry(s.backend, s.registry) +} + +func (s *store) Delete(key string) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.registry, key) + return saveRegistry(s.backend, s.registry) +} From dda6fc7eb702ab227c5f17beb002fbb92b3e3d98 Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Mon, 8 Dec 2025 17:05:54 +0200 Subject: [PATCH 09/10] use secretStorage to store oauth tokens --- cmd/src/login.go | 8 +------- cmd/src/main.go | 12 +++++------- internal/oauthdevice/device_flow.go | 25 +++++++++++++++++-------- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/cmd/src/login.go b/cmd/src/login.go index 88f010d09a..aebbe2ad86 100644 --- a/cmd/src/login.go +++ b/cmd/src/login.go @@ -11,7 +11,6 @@ import ( "github.com/sourcegraph/src-cli/internal/api" "github.com/sourcegraph/src-cli/internal/cmderrors" - "github.com/sourcegraph/src-cli/internal/keyring" "github.com/sourcegraph/src-cli/internal/oauthdevice" ) @@ -126,11 +125,6 @@ func loginCmd(ctx context.Context, p loginParams) error { noToken := cfg.AccessToken == "" endpointConflict := endpointArg != cfg.Endpoint - secretStore, err := keyring.Open() - if err != nil { - printProblem(fmt.Sprintf("could not open keyring for secret storage: %s", err)) - } - cfg.Endpoint = endpointArg if p.useDeviceFlow { @@ -141,7 +135,7 @@ func loginCmd(ctx context.Context, p loginParams) error { return cmderrors.ExitCode1 } - if err := oauthdevice.StoreToken(secretStore, token); err != nil { + if err := oauthdevice.StoreToken(token); err != nil { printProblem(fmt.Sprintf("Failed to store token in keyring store: %s", err)) return cmderrors.ExitCode1 } diff --git a/cmd/src/main.go b/cmd/src/main.go index 7fccd3e396..24dca7551e 100644 --- a/cmd/src/main.go +++ b/cmd/src/main.go @@ -15,7 +15,6 @@ import ( "github.com/sourcegraph/sourcegraph/lib/errors" "github.com/sourcegraph/src-cli/internal/api" - "github.com/sourcegraph/src-cli/internal/keyring" "github.com/sourcegraph/src-cli/internal/oauthdevice" ) @@ -133,13 +132,12 @@ func (c *config) apiClient(flags *api.Flags, out io.Writer) api.Client { ProxyURL: c.ProxyURL, ProxyPath: c.ProxyPath, } - store, err := keyring.Open() - if err != nil { - panic("HALP") - } - if t, err := oauthdevice.LoadToken(store, c.Endpoint); err == nil { - opts.OAuthToken = t + // Only use OAuth if we do not have SRC_ACCESS_TOKEN set + if c.AccessToken == "" { + if t, err := oauthdevice.LoadToken(c.Endpoint); err == nil { + opts.OAuthToken = t + } } return api.NewClient(opts) diff --git a/internal/oauthdevice/device_flow.go b/internal/oauthdevice/device_flow.go index 8937c7866b..6d3d0d0087 100644 --- a/internal/oauthdevice/device_flow.go +++ b/internal/oauthdevice/device_flow.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/sourcegraph/src-cli/internal/keyring" + "github.com/sourcegraph/src-cli/internal/secrets" "github.com/sourcegraph/sourcegraph/lib/errors" ) @@ -389,7 +389,11 @@ func (t *Token) ExpiringIn(d time.Duration) bool { return future.After(t.ExpiresAt) } -func StoreToken(store *keyring.Store, token *Token) error { +func StoreToken(token *Token) error { + store, err := secrets.Store() + if err != nil { + return err + } data, err := json.Marshal(token) if err != nil { return errors.Wrap(err, "failed to marshal token") @@ -399,18 +403,23 @@ func StoreToken(store *keyring.Store, token *Token) error { return errors.New("token endpoint cannot be empty when storing the token") } - key := fmt.Sprintf("%s <%s>", KeyOAuth, token.Endpoint) - return store.Set(key, data) + key := fmt.Sprintf("oauth[%s]", token.Endpoint) + return store.Put(key, data) } -func LoadToken(store *keyring.Store, endpoint string) (*Token, error) { - key := fmt.Sprintf("%s <%s>", KeyOAuth, endpoint) - var t Token +func LoadToken(endpoint string) (*Token, error) { + store, err := secrets.Store() + if err != nil { + return nil, err + } + + key := fmt.Sprintf("oauth[%s]", endpoint) data, err := store.Get(key) if err != nil { - return nil, errors.Wrap(err, "failed to get token from store") + return nil, err } + var t Token if err := json.Unmarshal(data, &t); err != nil { return nil, errors.Wrap(err, "failed to unmarshall token") } From 5a355f71ec2b55ebecaf3db0469e1fbff054866b Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Mon, 8 Dec 2025 17:07:00 +0200 Subject: [PATCH 10/10] build OAuthTransport in, if we're using OAuth Token --- cmd/src/login_test.go | 9 ++++++++- internal/api/api.go | 31 ++++++++++++++++++------------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/cmd/src/login_test.go b/cmd/src/login_test.go index 37fbf7a703..ae630fcd77 100644 --- a/cmd/src/login_test.go +++ b/cmd/src/login_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/sourcegraph/src-cli/internal/cmderrors" + "github.com/sourcegraph/src-cli/internal/oauthdevice" ) func TestLogin(t *testing.T) { @@ -18,7 +19,13 @@ func TestLogin(t *testing.T) { 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: oauthdevice.NewClient(oauthdevice.DefaultClientID), + }) return strings.TrimSpace(out.String()), err } diff --git a/internal/api/api.go b/internal/api/api.go index 1c769f7138..2a2dbe6415 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -90,25 +90,30 @@ type ClientOpts struct { OAuthToken *oauthdevice.Token } -func buildTransport(opts ClientOpts, flags *Flags) *http.Transport { - transport := http.DefaultTransport.(*http.Transport).Clone() +func buildTransport(opts ClientOpts, flags *Flags) http.RoundTripper { + var transport http.RoundTripper + { + tp := http.DefaultTransport.(*http.Transport).Clone() - if flags.insecureSkipVerify != nil && *flags.insecureSkipVerify { - transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - } + if flags.insecureSkipVerify != nil && *flags.insecureSkipVerify { + tp.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } - if transport.TLSClientConfig == nil { - transport.TLSClientConfig = &tls.Config{} - } + if tp.TLSClientConfig == nil { + tp.TLSClientConfig = &tls.Config{} + } + + if opts.ProxyURL != nil || opts.ProxyPath != "" { + tp = withProxyTransport(tp, opts.ProxyURL, opts.ProxyPath) + } - if opts.ProxyURL != nil || opts.ProxyPath != "" { - transport = withProxyTransport(transport, opts.ProxyURL, opts.ProxyPath) + transport = tp } - if opt.AccessToken == "" && opt.OAuthToken != nil { + if opts.AccessToken == "" && opts.OAuthToken != nil { transport = &oauthdevice.Transport{ - Base: transport, - Token: opts.OAuthToken + Base: transport, + Token: opts.OAuthToken, } }