Skip to content

Commit cb049b4

Browse files
committed
fix: address oauth review findings
Amp-Thread-ID: https://ampcode.com/threads/T-019d3412-2251-7197-b667-d42d29ab0ded
1 parent 59d79a3 commit cb049b4

24 files changed

Lines changed: 907 additions & 65 deletions

File tree

cmd/api/api.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ func (c *ApiCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
116116

117117
// Create an HTTP client with appropriate configuration
118118
client := httpClient.NewClient(
119-
f.Config.APIToken(),
119+
f.Token,
120120
httpClient.WithBaseURL(f.RestAPIClient.BaseURL.String()),
121121
httpClient.WithMaxRetries(3),
122122
httpClient.WithMaxRetryDelay(60*time.Second),

cmd/auth/login.go

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func LoginWithSession(f *factory.Factory, org string, session *oauth.Session) er
113113
}
114114

115115
func (c *LoginCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
116-
f, err := factory.New(factory.WithDebug(globals.EnableDebug()))
116+
f, err := factory.New(factory.WithDebug(globals.EnableDebug()), factory.WithoutAPIClients())
117117
if err != nil {
118118
return err
119119
}
@@ -178,23 +178,23 @@ func (c *LoginCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
178178
return fmt.Errorf("token exchange failed: %w", err)
179179
}
180180

181-
org, err := resolveOrganizationFromToken(ctx, f.Config.RESTAPIEndpoint(), tokenResp.AccessToken)
181+
orgs, err := resolveOrganizationsFromToken(ctx, f.Config.RESTAPIEndpoint(), tokenResp.AccessToken)
182182
if err != nil {
183183
return err
184184
}
185185

186-
session := tokenResp.Session(cfg.Host, time.Now())
187-
if err := LoginWithSession(f, org.Slug, session); err != nil {
186+
session := tokenResp.Session(cfg.Host, cfg.ClientID, time.Now())
187+
if err := storeSessionForOrganizations(f, orgs, session); err != nil {
188188
return err
189189
}
190190

191-
fmt.Printf("\n✅ Successfully authenticated with organization %q\n", org.Slug)
191+
fmt.Printf("\n✅ Successfully authenticated with organization %q\n", orgs[0].Slug)
192192
fmt.Printf(" Scopes: %s\n", tokenResp.Scope)
193193

194194
return nil
195195
}
196196

197-
func resolveOrganizationFromToken(ctx context.Context, baseURL, token string) (*buildkite.Organization, error) {
197+
func resolveOrganizationsFromToken(ctx context.Context, baseURL, token string) ([]buildkite.Organization, error) {
198198
client, err := buildkite.NewOpts(
199199
buildkite.WithBaseURL(baseURL),
200200
buildkite.WithTokenAuth(token),
@@ -203,13 +203,64 @@ func resolveOrganizationFromToken(ctx context.Context, baseURL, token string) (*
203203
return nil, fmt.Errorf("failed to create API client: %w", err)
204204
}
205205

206-
orgs, _, err := client.Organizations.List(ctx, nil)
207-
if err != nil {
208-
return nil, fmt.Errorf("failed to list organizations: %w", err)
206+
var allOrgs []buildkite.Organization
207+
page := 1
208+
for {
209+
orgs, resp, err := client.Organizations.List(ctx, &buildkite.OrganizationListOptions{
210+
ListOptions: buildkite.ListOptions{Page: page},
211+
})
212+
if err != nil {
213+
return nil, fmt.Errorf("failed to list organizations: %w", err)
214+
}
215+
allOrgs = append(allOrgs, orgs...)
216+
if resp == nil || resp.NextPage == 0 {
217+
break
218+
}
219+
page = resp.NextPage
209220
}
210-
if len(orgs) == 0 {
221+
222+
if len(allOrgs) == 0 {
211223
return nil, fmt.Errorf("no organizations found for this token")
212224
}
213225

226+
return allOrgs, nil
227+
}
228+
229+
func resolveOrganizationFromToken(ctx context.Context, baseURL, token string) (*buildkite.Organization, error) {
230+
orgs, err := resolveOrganizationsFromToken(ctx, baseURL, token)
231+
if err != nil {
232+
return nil, err
233+
}
234+
214235
return &orgs[0], nil
215236
}
237+
238+
func storeSessionForOrganizations(f *factory.Factory, orgs []buildkite.Organization, session *oauth.Session) error {
239+
if len(orgs) == 0 {
240+
return errors.New("no organizations found for this token")
241+
}
242+
if err := LoginWithSession(f, orgs[0].Slug, session); err != nil {
243+
return err
244+
}
245+
246+
kr := keyring.New()
247+
seen := map[string]struct{}{orgs[0].Slug: {}}
248+
for _, org := range orgs[1:] {
249+
if org.Slug == "" {
250+
continue
251+
}
252+
if _, exists := seen[org.Slug]; exists {
253+
continue
254+
}
255+
seen[org.Slug] = struct{}{}
256+
257+
if err := kr.SetSession(org.Slug, session); err != nil {
258+
return fmt.Errorf("failed to store token in keychain: %w", err)
259+
}
260+
if err := f.Config.EnsureOrganization(org.Slug); err != nil {
261+
return fmt.Errorf("failed to register organization in config: %w", err)
262+
}
263+
}
264+
265+
return nil
266+
}

cmd/auth/login_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ import (
66
"net/http"
77
"net/http/httptest"
88
"testing"
9+
10+
"github.com/buildkite/cli/v3/internal/config"
11+
"github.com/buildkite/cli/v3/pkg/cmd/factory"
12+
"github.com/buildkite/cli/v3/pkg/keyring"
13+
"github.com/buildkite/cli/v3/pkg/oauth"
14+
buildkite "github.com/buildkite/go-buildkite/v4"
15+
"github.com/spf13/afero"
916
)
1017

1118
func TestResolveOrganizationFromTokenUsesConfiguredBaseURL(t *testing.T) {
@@ -32,3 +39,74 @@ func TestResolveOrganizationFromTokenUsesConfiguredBaseURL(t *testing.T) {
3239
t.Fatalf("Slug = %q, want test-org", org.Slug)
3340
}
3441
}
42+
43+
func TestStoreSessionForOrganizationsStoresAllAccessibleOrgs(t *testing.T) {
44+
keyring.MockForTesting()
45+
46+
f := &factory.Factory{
47+
Config: config.New(afero.NewMemMapFs(), nil),
48+
}
49+
session := &oauth.Session{
50+
Version: oauth.SessionVersion,
51+
AccessToken: "bkua_access",
52+
TokenType: "Bearer",
53+
}
54+
55+
orgs := []buildkite.Organization{
56+
{Slug: "test-org"},
57+
{Slug: "other-org"},
58+
{Slug: "other-org"},
59+
}
60+
61+
if err := storeSessionForOrganizations(f, orgs, session); err != nil {
62+
t.Fatalf("storeSessionForOrganizations returned error: %v", err)
63+
}
64+
65+
kr := keyring.New()
66+
for _, slug := range []string{"test-org", "other-org"} {
67+
storedSession, err := kr.GetSession(slug)
68+
if err != nil {
69+
t.Fatalf("GetSession(%q) returned error: %v", slug, err)
70+
}
71+
if storedSession.AccessToken != "bkua_access" {
72+
t.Fatalf("stored access token for %q = %q, want bkua_access", slug, storedSession.AccessToken)
73+
}
74+
}
75+
76+
if got := f.Config.OrganizationSlug(); got != "test-org" {
77+
t.Fatalf("OrganizationSlug() = %q, want test-org", got)
78+
}
79+
}
80+
81+
func TestResolveOrganizationsFromTokenPaginates(t *testing.T) {
82+
var server *httptest.Server
83+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84+
page := r.URL.Query().Get("page")
85+
w.Header().Set("Content-Type", "application/json")
86+
switch page {
87+
case "", "1":
88+
w.Header().Set("Link", `<`+server.URL+`/v2/organizations?page=2>; rel="next"`)
89+
if err := json.NewEncoder(w).Encode([]map[string]any{{"slug": "org-one"}}); err != nil {
90+
t.Fatalf("Encode page 1 returned error: %v", err)
91+
}
92+
case "2":
93+
if err := json.NewEncoder(w).Encode([]map[string]any{{"slug": "org-two"}}); err != nil {
94+
t.Fatalf("Encode page 2 returned error: %v", err)
95+
}
96+
default:
97+
t.Fatalf("unexpected page query %q", page)
98+
}
99+
}))
100+
defer server.Close()
101+
102+
orgs, err := resolveOrganizationsFromToken(context.Background(), server.URL, "bkua_test_token")
103+
if err != nil {
104+
t.Fatalf("resolveOrganizationsFromToken returned error: %v", err)
105+
}
106+
if len(orgs) != 2 {
107+
t.Fatalf("len(orgs) = %d, want 2", len(orgs))
108+
}
109+
if orgs[0].Slug != "org-one" || orgs[1].Slug != "org-two" {
110+
t.Fatalf("org slugs = [%q %q], want [org-one org-two]", orgs[0].Slug, orgs[1].Slug)
111+
}
112+
}

cmd/auth/logout.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/buildkite/cli/v3/internal/cli"
88
"github.com/buildkite/cli/v3/pkg/cmd/factory"
99
"github.com/buildkite/cli/v3/pkg/keyring"
10+
"github.com/buildkite/cli/v3/pkg/oauth"
1011
)
1112

1213
type LogoutCmd struct {
@@ -15,7 +16,7 @@ type LogoutCmd struct {
1516
}
1617

1718
func (c *LogoutCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
18-
f, err := factory.New(factory.WithDebug(globals.EnableDebug()))
19+
f, err := factory.New(factory.WithDebug(globals.EnableDebug()), factory.WithoutAPIClients())
1920
if err != nil {
2021
return err
2122
}
@@ -59,13 +60,41 @@ func (c *LogoutCmd) logoutOrg(f *factory.Factory) error {
5960

6061
kr := keyring.New()
6162
if kr.IsAvailable() {
63+
var currentSession *oauth.Session
64+
currentSession, _ = kr.GetSession(org)
6265
if err := kr.Delete(org); err != nil {
6366
fmt.Printf("Warning: could not remove token from keychain: %v\n", err)
6467
} else {
68+
c.deleteSiblingOAuthSessions(f, kr, org, currentSession)
6569
fmt.Println("Token removed from system keychain.")
6670
}
6771
}
6872

6973
fmt.Printf("Logged out of organization %q\n", org)
7074
return nil
7175
}
76+
77+
func (c *LogoutCmd) deleteSiblingOAuthSessions(f *factory.Factory, kr *keyring.Keyring, org string, session *oauth.Session) {
78+
if session == nil || session.RefreshToken == "" {
79+
return
80+
}
81+
82+
for _, sibling := range f.Config.ConfiguredOrganizations() {
83+
if sibling == "" || sibling == org {
84+
continue
85+
}
86+
87+
siblingSession, err := kr.GetSession(sibling)
88+
if err != nil || siblingSession == nil {
89+
continue
90+
}
91+
if siblingSession.Host != session.Host || siblingSession.ClientID != session.ClientID {
92+
continue
93+
}
94+
if siblingSession.RefreshToken != session.RefreshToken || siblingSession.AccessToken != session.AccessToken {
95+
continue
96+
}
97+
98+
_ = kr.Delete(sibling)
99+
}
100+
}

cmd/auth/logout_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package auth
2+
3+
import (
4+
"testing"
5+
6+
"github.com/buildkite/cli/v3/internal/config"
7+
"github.com/buildkite/cli/v3/pkg/cmd/factory"
8+
"github.com/buildkite/cli/v3/pkg/keyring"
9+
"github.com/buildkite/cli/v3/pkg/oauth"
10+
"github.com/spf13/afero"
11+
)
12+
13+
func TestLogoutOrgDeletesSiblingOAuthAliases(t *testing.T) {
14+
keyring.MockForTesting()
15+
16+
conf := config.New(afero.NewMemMapFs(), nil)
17+
if err := conf.EnsureOrganization("org-a"); err != nil {
18+
t.Fatalf("EnsureOrganization org-a returned error: %v", err)
19+
}
20+
if err := conf.EnsureOrganization("org-b"); err != nil {
21+
t.Fatalf("EnsureOrganization org-b returned error: %v", err)
22+
}
23+
if err := conf.SelectOrganization("org-a", false); err != nil {
24+
t.Fatalf("SelectOrganization returned error: %v", err)
25+
}
26+
27+
session := &oauth.Session{
28+
Version: oauth.SessionVersion,
29+
Host: "buildkite.localhost",
30+
ClientID: "buildkite-cli",
31+
AccessToken: "bkua_access",
32+
RefreshToken: "bkrt_refresh",
33+
TokenType: "Bearer",
34+
}
35+
36+
kr := keyring.New()
37+
if err := kr.SetSession("org-a", session); err != nil {
38+
t.Fatalf("SetSession org-a returned error: %v", err)
39+
}
40+
if err := kr.SetSession("org-b", session); err != nil {
41+
t.Fatalf("SetSession org-b returned error: %v", err)
42+
}
43+
44+
cmd := &LogoutCmd{Org: "org-a"}
45+
f := &factory.Factory{Config: conf}
46+
if err := cmd.logoutOrg(f); err != nil {
47+
t.Fatalf("logoutOrg returned error: %v", err)
48+
}
49+
50+
if _, err := kr.GetSession("org-a"); err == nil {
51+
t.Fatal("expected org-a session to be deleted")
52+
}
53+
if _, err := kr.GetSession("org-b"); err == nil {
54+
t.Fatal("expected org-b sibling session to be deleted")
55+
}
56+
}

cmd/auth/switch.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Examples:
2626
}
2727

2828
func (c *SwitchCmd) Run(globals cli.GlobalFlags) error {
29-
f, err := factory.New(factory.WithDebug(globals.EnableDebug()))
29+
f, err := factory.New(factory.WithDebug(globals.EnableDebug()), factory.WithoutAPIClients())
3030
if err != nil {
3131
return err
3232
}

cmd/config/get.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func (c *GetCmd) Run() error {
3636
return err
3737
}
3838

39-
f, err := factory.New()
39+
f, err := factory.New(factory.WithoutAPIClients())
4040
if err != nil {
4141
return err
4242
}

cmd/config/list.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Examples:
2424
}
2525

2626
func (c *ListCmd) Run() error {
27-
f, err := factory.New()
27+
f, err := factory.New(factory.WithoutAPIClients())
2828
if err != nil {
2929
return err
3030
}

cmd/config/set.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func (c *SetCmd) Run() error {
5757
return fmt.Errorf("%s can only be set in user config (not --local)", key)
5858
}
5959

60-
f, err := factory.New()
60+
f, err := factory.New(factory.WithoutAPIClients())
6161
if err != nil {
6262
return err
6363
}

cmd/config/unset.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func (c *UnsetCmd) Run() error {
3636
return fmt.Errorf("%s can only be unset from user config (not --local)", key)
3737
}
3838

39-
f, err := factory.New()
39+
f, err := factory.New(factory.WithoutAPIClients())
4040
if err != nil {
4141
return err
4242
}

0 commit comments

Comments
 (0)