Skip to content

Commit f6f8e13

Browse files
committed
fix: persist refreshable OAuth sessions
Amp-Thread-ID: https://ampcode.com/threads/T-019d3412-2251-7197-b667-d42d29ab0ded
1 parent 16256d5 commit f6f8e13

9 files changed

Lines changed: 628 additions & 42 deletions

File tree

cmd/auth/login.go

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,35 @@ func LoginWithToken(f *factory.Factory, org, token string) error {
7373
return nil
7474
}
7575

76+
// LoginWithSession stores an OAuth session for an organization in the system keychain.
77+
func LoginWithSession(f *factory.Factory, org string, session *oauth.Session) error {
78+
if org == "" {
79+
return errors.New("organization cannot be empty")
80+
}
81+
if session == nil || session.AccessToken == "" {
82+
return errors.New("oauth session must include an access token")
83+
}
84+
85+
kr := keyring.New()
86+
if !kr.IsAvailable() {
87+
return errors.New("system keychain is not available; cannot store token")
88+
}
89+
if err := kr.SetSession(org, session); err != nil {
90+
return fmt.Errorf("failed to store token in keychain: %w", err)
91+
}
92+
fmt.Println("Token stored securely in system keychain.")
93+
94+
if err := f.Config.EnsureOrganization(org); err != nil {
95+
return fmt.Errorf("failed to register organization in config: %w", err)
96+
}
97+
98+
if err := f.Config.SelectOrganization(org, f.GitRepository != nil); err != nil {
99+
return fmt.Errorf("failed to select organization: %w", err)
100+
}
101+
102+
return nil
103+
}
104+
76105
func (c *LoginCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
77106
f, err := factory.New(factory.WithDebug(globals.EnableDebug()))
78107
if err != nil {
@@ -95,8 +124,7 @@ func (c *LoginCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
95124
// Create OAuth flow
96125
cfg := &oauth.Config{
97126
// Host default handled via NewFlow, omitted to allow usage of BUILDKITE_HOST
98-
ClientID: oauth.DefaultClientID,
99-
Scopes: c.Scopes,
127+
Scopes: c.Scopes,
100128
}
101129

102130
flow, err := oauth.NewFlow(cfg)
@@ -135,23 +163,13 @@ func (c *LoginCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
135163
return fmt.Errorf("token exchange failed: %w", err)
136164
}
137165

138-
// Resolve org from the API using the new token
139-
client, err := buildkite.NewOpts(buildkite.WithTokenAuth(tokenResp.AccessToken))
140-
if err != nil {
141-
return fmt.Errorf("failed to create API client: %w", err)
142-
}
143-
144-
orgs, _, err := client.Organizations.List(ctx, nil)
166+
org, err := resolveOrganizationFromToken(ctx, f.Config.RESTAPIEndpoint(), tokenResp.AccessToken)
145167
if err != nil {
146-
return fmt.Errorf("failed to list organizations: %w", err)
147-
}
148-
if len(orgs) == 0 {
149-
return fmt.Errorf("no organizations found for this token")
168+
return err
150169
}
151170

152-
org := orgs[0]
153-
154-
if err := LoginWithToken(f, org.Slug, tokenResp.AccessToken); err != nil {
171+
session := tokenResp.Session(cfg.Host, time.Now())
172+
if err := LoginWithSession(f, org.Slug, session); err != nil {
155173
return err
156174
}
157175

@@ -160,3 +178,23 @@ func (c *LoginCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
160178

161179
return nil
162180
}
181+
182+
func resolveOrganizationFromToken(ctx context.Context, baseURL, token string) (*buildkite.Organization, error) {
183+
client, err := buildkite.NewOpts(
184+
buildkite.WithBaseURL(baseURL),
185+
buildkite.WithTokenAuth(token),
186+
)
187+
if err != nil {
188+
return nil, fmt.Errorf("failed to create API client: %w", err)
189+
}
190+
191+
orgs, _, err := client.Organizations.List(ctx, nil)
192+
if err != nil {
193+
return nil, fmt.Errorf("failed to list organizations: %w", err)
194+
}
195+
if len(orgs) == 0 {
196+
return nil, fmt.Errorf("no organizations found for this token")
197+
}
198+
199+
return &orgs[0], nil
200+
}

cmd/auth/login_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
)
10+
11+
func TestResolveOrganizationFromTokenUsesConfiguredBaseURL(t *testing.T) {
12+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13+
if got := r.Header.Get("Authorization"); got != "Bearer bkua_test_token" {
14+
t.Fatalf("Authorization = %q, want Bearer bkua_test_token", got)
15+
}
16+
17+
w.Header().Set("Content-Type", "application/json")
18+
if err := json.NewEncoder(w).Encode([]map[string]any{{"slug": "test-org"}}); err != nil {
19+
t.Fatalf("Encode returned error: %v", err)
20+
}
21+
}))
22+
defer server.Close()
23+
24+
org, err := resolveOrganizationFromToken(context.Background(), server.URL, "bkua_test_token")
25+
if err != nil {
26+
t.Fatalf("resolveOrganizationFromToken returned error: %v", err)
27+
}
28+
if org == nil {
29+
t.Fatal("resolveOrganizationFromToken returned nil organization")
30+
}
31+
if org.Slug != "test-org" {
32+
t.Fatalf("Slug = %q, want test-org", org.Slug)
33+
}
34+
}

internal/config/config.go

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,24 @@
66
package config
77

88
import (
9+
"context"
910
"errors"
1011
"fmt"
1112
"io"
1213
"maps"
14+
"net/url"
1315
"os"
1416
"path/filepath"
1517
"runtime"
1618
"slices"
1719
"strconv"
20+
"strings"
1821
"sync"
22+
"time"
1923

2024
"github.com/buildkite/cli/v3/internal/pipeline"
2125
"github.com/buildkite/cli/v3/pkg/keyring"
26+
"github.com/buildkite/cli/v3/pkg/oauth"
2227
buildkite "github.com/buildkite/go-buildkite/v4"
2328
git "github.com/go-git/go-git/v5"
2429
"github.com/goccy/go-yaml"
@@ -137,8 +142,14 @@ func (conf *Config) APITokenForOrg(org string) string {
137142

138143
kr := keyring.New()
139144
if kr.IsAvailable() {
140-
if token, err := kr.Get(org); err == nil && token != "" {
141-
return token
145+
if session, err := kr.GetSession(org); err == nil && session != nil && session.AccessToken != "" {
146+
refreshedSession, refreshErr := conf.refreshOAuthSession(org, kr, session)
147+
if refreshedSession != nil && refreshedSession.AccessToken != "" {
148+
if refreshErr != nil {
149+
fmt.Fprintf(os.Stderr, "Warning: failed to refresh OAuth token for %q: %v\n", org, refreshErr)
150+
}
151+
return refreshedSession.AccessToken
152+
}
142153
}
143154
}
144155

@@ -213,12 +224,33 @@ func (conf *Config) GetGraphQLEndpoint() string {
213224
func (conf *Config) RESTAPIEndpoint() string {
214225
value := os.Getenv("BUILDKITE_REST_API_ENDPOINT")
215226
if value != "" {
216-
return value
227+
return normaliseRESTAPIEndpoint(value)
217228
}
218229

219230
return buildkite.DefaultBaseURL
220231
}
221232

233+
func normaliseRESTAPIEndpoint(value string) string {
234+
parsed, err := url.Parse(value)
235+
if err != nil {
236+
return value
237+
}
238+
239+
trimmedPath := strings.TrimRight(parsed.Path, "/")
240+
if !strings.HasSuffix(trimmedPath, "/v2") {
241+
return value
242+
}
243+
244+
parsed.Path = strings.TrimSuffix(trimmedPath, "/v2")
245+
if parsed.Path == "" {
246+
parsed.Path = "/"
247+
} else {
248+
parsed.Path += "/"
249+
}
250+
251+
return parsed.String()
252+
}
253+
222254
func (conf *Config) PagerDisabled() bool {
223255
if v, ok := lookupBoolEnv("BUILDKITE_NO_PAGER"); ok {
224256
return v
@@ -517,3 +549,24 @@ func (conf *Config) writeUser() error {
517549
func (conf *Config) writeLocal() error {
518550
return writeFileConfig(conf.fs, conf.localPath, conf.local)
519551
}
552+
553+
func (conf *Config) refreshOAuthSession(org string, kr *keyring.Keyring, session *oauth.Session) (*oauth.Session, error) {
554+
if session == nil || session.AccessToken == "" {
555+
return nil, nil
556+
}
557+
if !session.NeedsRefresh(time.Now()) {
558+
return session, nil
559+
}
560+
561+
refreshedToken, err := oauth.RefreshAccessToken(context.Background(), &oauth.Config{Host: session.Host}, session.RefreshToken, session.Scope)
562+
if err != nil {
563+
return session, err
564+
}
565+
566+
refreshedSession := session.Update(refreshedToken, time.Now())
567+
if err := kr.SetSession(org, refreshedSession); err != nil {
568+
return refreshedSession, err
569+
}
570+
571+
return refreshedSession, nil
572+
}

internal/config/config_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package config
22

33
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
47
"os"
58
"path/filepath"
69
"testing"
710

11+
buildkite "github.com/buildkite/go-buildkite/v4"
812
"github.com/spf13/afero"
913
)
1014

@@ -100,6 +104,33 @@ func TestConfig(t *testing.T) {
100104
}
101105
})
102106

107+
t.Run("RESTAPIEndpoint strips version suffix from env override", func(t *testing.T) {
108+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
109+
if r.URL.Path != "/v2/organizations" {
110+
t.Fatalf("request path = %q, want /v2/organizations", r.URL.Path)
111+
}
112+
113+
w.Header().Set("Content-Type", "application/json")
114+
_, _ = w.Write([]byte(`[]`))
115+
}))
116+
defer server.Close()
117+
118+
setEnv(t, "BUILDKITE_REST_API_ENDPOINT", server.URL+"/v2")
119+
120+
conf := New(afero.NewMemMapFs(), nil)
121+
client, err := buildkite.NewOpts(
122+
buildkite.WithBaseURL(conf.RESTAPIEndpoint()),
123+
buildkite.WithTokenAuth("bkua_test_token"),
124+
)
125+
if err != nil {
126+
t.Fatalf("NewOpts returned error: %v", err)
127+
}
128+
129+
if _, _, err := client.Organizations.List(context.Background(), nil); err != nil {
130+
t.Fatalf("Organizations.List returned error: %v", err)
131+
}
132+
})
133+
103134
t.Run("loadFileConfig returns error on invalid yaml", func(t *testing.T) {
104135
fs := afero.NewMemMapFs()
105136
path := filepath.Join(t.TempDir(), "bk.yaml")
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package config
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
"time"
9+
10+
"github.com/buildkite/cli/v3/pkg/keyring"
11+
"github.com/buildkite/cli/v3/pkg/oauth"
12+
"github.com/spf13/afero"
13+
)
14+
15+
func TestAPITokenForOrgRefreshesStoredOAuthSession(t *testing.T) {
16+
keyring.MockForTesting()
17+
t.Setenv(oauth.EnvClientID, "env-client")
18+
t.Setenv(oauth.LegacyEnvClientID, "")
19+
20+
var requests int
21+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22+
requests++
23+
24+
if r.URL.Path != "/oauth/token" {
25+
t.Fatalf("unexpected token path %q", r.URL.Path)
26+
}
27+
if err := r.ParseForm(); err != nil {
28+
t.Fatalf("ParseForm returned error: %v", err)
29+
}
30+
if got := r.Form.Get("grant_type"); got != "refresh_token" {
31+
t.Fatalf("grant_type = %q, want refresh_token", got)
32+
}
33+
if got := r.Form.Get("refresh_token"); got != "bkrt_old_refresh" {
34+
t.Fatalf("refresh_token = %q, want bkrt_old_refresh", got)
35+
}
36+
37+
w.Header().Set("Content-Type", "application/json")
38+
if err := json.NewEncoder(w).Encode(oauth.TokenResponse{
39+
AccessToken: "bkua_refreshed_access",
40+
RefreshToken: "bkrt_rotated_refresh",
41+
TokenType: "Bearer",
42+
Scope: "read_user read_organizations",
43+
ExpiresIn: 3600,
44+
}); err != nil {
45+
t.Fatalf("Encode returned error: %v", err)
46+
}
47+
}))
48+
defer server.Close()
49+
50+
conf := New(afero.NewMemMapFs(), nil)
51+
kr := keyring.New()
52+
if err := kr.SetSession("test-org", &oauth.Session{
53+
Version: oauth.SessionVersion,
54+
Host: server.URL,
55+
AccessToken: "bkua_expired_access",
56+
RefreshToken: "bkrt_old_refresh",
57+
TokenType: "Bearer",
58+
Scope: "read_user read_organizations",
59+
ExpiresAt: time.Now().Add(-time.Minute),
60+
}); err != nil {
61+
t.Fatalf("SetSession returned error: %v", err)
62+
}
63+
64+
token := conf.APITokenForOrg("test-org")
65+
if token != "bkua_refreshed_access" {
66+
t.Fatalf("APITokenForOrg() = %q, want bkua_refreshed_access", token)
67+
}
68+
if requests != 1 {
69+
t.Fatalf("refresh requests = %d, want 1", requests)
70+
}
71+
72+
session, err := kr.GetSession("test-org")
73+
if err != nil {
74+
t.Fatalf("GetSession returned error: %v", err)
75+
}
76+
if session.AccessToken != "bkua_refreshed_access" {
77+
t.Fatalf("stored AccessToken = %q, want bkua_refreshed_access", session.AccessToken)
78+
}
79+
if session.RefreshToken != "bkrt_rotated_refresh" {
80+
t.Fatalf("stored RefreshToken = %q, want bkrt_rotated_refresh", session.RefreshToken)
81+
}
82+
if !session.ExpiresAt.After(time.Now()) {
83+
t.Fatalf("stored ExpiresAt = %s, want a future timestamp", session.ExpiresAt)
84+
}
85+
}

0 commit comments

Comments
 (0)