Skip to content
Draft
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
2 changes: 1 addition & 1 deletion cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func (c *ApiCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {

// Create an HTTP client with appropriate configuration
client := httpClient.NewClient(
f.Config.APIToken(),
f.Token,
httpClient.WithBaseURL(f.RestAPIClient.BaseURL.String()),
httpClient.WithMaxRetries(3),
httpClient.WithMaxRetryDelay(60*time.Second),
Expand Down
123 changes: 106 additions & 17 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,37 @@ func LoginWithToken(f *factory.Factory, org, token string) error {
return nil
}

// LoginWithSession stores an OAuth session for an organization in the system keychain.
func LoginWithSession(f *factory.Factory, org string, session *oauth.Session) error {
if org == "" {
return errors.New("organization cannot be empty")
}
if session == nil || session.AccessToken == "" {
return errors.New("oauth session must include an access token")
}

kr := keyring.New()
if !kr.IsAvailable() {
return errors.New("system keychain is not available; cannot store token")
}
if err := kr.SetSession(org, session); err != nil {
return fmt.Errorf("failed to store token in keychain: %w", err)
}
fmt.Println("Token stored securely in system keychain.")

if err := f.Config.EnsureOrganization(org); err != nil {
return fmt.Errorf("failed to register organization in config: %w", err)
}

if err := f.Config.SelectOrganization(org, f.GitRepository != nil); err != nil {
return fmt.Errorf("failed to select organization: %w", err)
}

return nil
}

func (c *LoginCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
f, err := factory.New(factory.WithDebug(globals.EnableDebug()))
f, err := factory.New(factory.WithDebug(globals.EnableDebug()), factory.WithoutAPIClients())
if err != nil {
return err
}
Expand All @@ -102,16 +131,15 @@ func (c *LoginCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
return errors.New("--org requires --token. Use `bk auth login` for OAuth or `bk auth login --org <org> --token <token>` for token login")
}

// Resolve scope groups (e.g., "read_only" individual read_* scopes).
// When --scopes is empty, no scope parameter is sent and the token
// inherits the user's full Buildkite permissions.
// Resolve scope groups (e.g. "read_only" to individual read_* scopes).
// When --scopes is empty, NewFlow defaults to requesting the full known
// scope set and Buildkite grants the subset the user can actually use.
resolvedScopes := oauth.ResolveScopes(c.Scopes)

// Create OAuth flow
cfg := &oauth.Config{
// Host default handled via NewFlow, omitted to allow usage of BUILDKITE_HOST
ClientID: oauth.DefaultClientID,
Scopes: resolvedScopes,
Scopes: resolvedScopes,
}

flow, err := oauth.NewFlow(cfg)
Expand Down Expand Up @@ -150,28 +178,89 @@ func (c *LoginCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
return fmt.Errorf("token exchange failed: %w", err)
}

// Resolve org from the API using the new token
client, err := buildkite.NewOpts(buildkite.WithTokenAuth(tokenResp.AccessToken))
orgs, err := resolveOrganizationsFromToken(ctx, f.Config.RESTAPIEndpoint(), tokenResp.AccessToken)
if err != nil {
return fmt.Errorf("failed to create API client: %w", err)
return err
}

session := tokenResp.Session(cfg.Host, cfg.ClientID, time.Now())
if err := storeSessionForOrganizations(f, orgs, session); err != nil {
return err
}

orgs, _, err := client.Organizations.List(ctx, nil)
fmt.Printf("\n✅ Successfully authenticated with organization %q\n", orgs[0].Slug)
fmt.Printf(" Scopes: %s\n", tokenResp.Scope)

return nil
}

func resolveOrganizationsFromToken(ctx context.Context, baseURL, token string) ([]buildkite.Organization, error) {
client, err := buildkite.NewOpts(
buildkite.WithBaseURL(baseURL),
buildkite.WithTokenAuth(token),
)
if err != nil {
return fmt.Errorf("failed to list organizations: %w", err)
return nil, fmt.Errorf("failed to create API client: %w", err)
}
if len(orgs) == 0 {
return fmt.Errorf("no organizations found for this token")

var allOrgs []buildkite.Organization
page := 1
for {
orgs, resp, err := client.Organizations.List(ctx, &buildkite.OrganizationListOptions{
ListOptions: buildkite.ListOptions{Page: page},
})
if err != nil {
return nil, fmt.Errorf("failed to list organizations: %w", err)
}
allOrgs = append(allOrgs, orgs...)
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}

org := orgs[0]
if len(allOrgs) == 0 {
return nil, fmt.Errorf("no organizations found for this token")
}

if err := LoginWithToken(f, org.Slug, tokenResp.AccessToken); err != nil {
return allOrgs, nil
}

func resolveOrganizationFromToken(ctx context.Context, baseURL, token string) (*buildkite.Organization, error) {
orgs, err := resolveOrganizationsFromToken(ctx, baseURL, token)
if err != nil {
return nil, err
}

return &orgs[0], nil
}

func storeSessionForOrganizations(f *factory.Factory, orgs []buildkite.Organization, session *oauth.Session) error {
if len(orgs) == 0 {
return errors.New("no organizations found for this token")
}
if err := LoginWithSession(f, orgs[0].Slug, session); err != nil {
return err
}

fmt.Printf("\n✅ Successfully authenticated with organization %q\n", org.Slug)
fmt.Printf(" Scopes: %s\n", tokenResp.Scope)
kr := keyring.New()
seen := map[string]struct{}{orgs[0].Slug: {}}
for _, org := range orgs[1:] {
if org.Slug == "" {
continue
}
if _, exists := seen[org.Slug]; exists {
continue
}
seen[org.Slug] = struct{}{}

if err := kr.SetSession(org.Slug, session); err != nil {
return fmt.Errorf("failed to store token in keychain: %w", err)
}
if err := f.Config.EnsureOrganization(org.Slug); err != nil {
return fmt.Errorf("failed to register organization in config: %w", err)
}
}

return nil
}
112 changes: 112 additions & 0 deletions cmd/auth/login_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package auth

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/buildkite/cli/v3/internal/config"
"github.com/buildkite/cli/v3/pkg/cmd/factory"
"github.com/buildkite/cli/v3/pkg/keyring"
"github.com/buildkite/cli/v3/pkg/oauth"
buildkite "github.com/buildkite/go-buildkite/v4"
"github.com/spf13/afero"
)

func TestResolveOrganizationFromTokenUsesConfiguredBaseURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer bkua_test_token" {
t.Fatalf("Authorization = %q, want Bearer bkua_test_token", got)
}

w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode([]map[string]any{{"slug": "test-org"}}); err != nil {
t.Fatalf("Encode returned error: %v", err)
}
}))
defer server.Close()

org, err := resolveOrganizationFromToken(context.Background(), server.URL, "bkua_test_token")
if err != nil {
t.Fatalf("resolveOrganizationFromToken returned error: %v", err)
}
if org == nil {
t.Fatal("resolveOrganizationFromToken returned nil organization")
}
if org.Slug != "test-org" {
t.Fatalf("Slug = %q, want test-org", org.Slug)
}
}

func TestStoreSessionForOrganizationsStoresAllAccessibleOrgs(t *testing.T) {
keyring.MockForTesting()

f := &factory.Factory{
Config: config.New(afero.NewMemMapFs(), nil),
}
session := &oauth.Session{
Version: oauth.SessionVersion,
AccessToken: "bkua_access",
TokenType: "Bearer",
}

orgs := []buildkite.Organization{
{Slug: "test-org"},
{Slug: "other-org"},
{Slug: "other-org"},
}

if err := storeSessionForOrganizations(f, orgs, session); err != nil {
t.Fatalf("storeSessionForOrganizations returned error: %v", err)
}

kr := keyring.New()
for _, slug := range []string{"test-org", "other-org"} {
storedSession, err := kr.GetSession(slug)
if err != nil {
t.Fatalf("GetSession(%q) returned error: %v", slug, err)
}
if storedSession.AccessToken != "bkua_access" {
t.Fatalf("stored access token for %q = %q, want bkua_access", slug, storedSession.AccessToken)
}
}

if got := f.Config.OrganizationSlug(); got != "test-org" {
t.Fatalf("OrganizationSlug() = %q, want test-org", got)
}
}

func TestResolveOrganizationsFromTokenPaginates(t *testing.T) {
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page := r.URL.Query().Get("page")
w.Header().Set("Content-Type", "application/json")
switch page {
case "", "1":
w.Header().Set("Link", `<`+server.URL+`/v2/organizations?page=2>; rel="next"`)
if err := json.NewEncoder(w).Encode([]map[string]any{{"slug": "org-one"}}); err != nil {
t.Fatalf("Encode page 1 returned error: %v", err)
}
case "2":
if err := json.NewEncoder(w).Encode([]map[string]any{{"slug": "org-two"}}); err != nil {
t.Fatalf("Encode page 2 returned error: %v", err)
}
default:
t.Fatalf("unexpected page query %q", page)
}
}))
defer server.Close()

orgs, err := resolveOrganizationsFromToken(context.Background(), server.URL, "bkua_test_token")
if err != nil {
t.Fatalf("resolveOrganizationsFromToken returned error: %v", err)
}
if len(orgs) != 2 {
t.Fatalf("len(orgs) = %d, want 2", len(orgs))
}
if orgs[0].Slug != "org-one" || orgs[1].Slug != "org-two" {
t.Fatalf("org slugs = [%q %q], want [org-one org-two]", orgs[0].Slug, orgs[1].Slug)
}
}
31 changes: 30 additions & 1 deletion cmd/auth/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/buildkite/cli/v3/internal/cli"
"github.com/buildkite/cli/v3/pkg/cmd/factory"
"github.com/buildkite/cli/v3/pkg/keyring"
"github.com/buildkite/cli/v3/pkg/oauth"
)

type LogoutCmd struct {
Expand All @@ -15,7 +16,7 @@ type LogoutCmd struct {
}

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

kr := keyring.New()
if kr.IsAvailable() {
var currentSession *oauth.Session
currentSession, _ = kr.GetSession(org)
if err := kr.Delete(org); err != nil {
fmt.Printf("Warning: could not remove token from keychain: %v\n", err)
} else {
c.deleteSiblingOAuthSessions(f, kr, org, currentSession)
fmt.Println("Token removed from system keychain.")
}
}

fmt.Printf("Logged out of organization %q\n", org)
return nil
}

func (c *LogoutCmd) deleteSiblingOAuthSessions(f *factory.Factory, kr *keyring.Keyring, org string, session *oauth.Session) {
if session == nil || session.RefreshToken == "" {
return
}

for _, sibling := range f.Config.ConfiguredOrganizations() {
if sibling == "" || sibling == org {
continue
}

siblingSession, err := kr.GetSession(sibling)
if err != nil || siblingSession == nil {
continue
}
if siblingSession.Host != session.Host || siblingSession.ClientID != session.ClientID {
continue
}
if siblingSession.RefreshToken != session.RefreshToken || siblingSession.AccessToken != session.AccessToken {
continue
}

_ = kr.Delete(sibling)
}
}
56 changes: 56 additions & 0 deletions cmd/auth/logout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package auth

import (
"testing"

"github.com/buildkite/cli/v3/internal/config"
"github.com/buildkite/cli/v3/pkg/cmd/factory"
"github.com/buildkite/cli/v3/pkg/keyring"
"github.com/buildkite/cli/v3/pkg/oauth"
"github.com/spf13/afero"
)

func TestLogoutOrgDeletesSiblingOAuthAliases(t *testing.T) {
keyring.MockForTesting()

conf := config.New(afero.NewMemMapFs(), nil)
if err := conf.EnsureOrganization("org-a"); err != nil {
t.Fatalf("EnsureOrganization org-a returned error: %v", err)
}
if err := conf.EnsureOrganization("org-b"); err != nil {
t.Fatalf("EnsureOrganization org-b returned error: %v", err)
}
if err := conf.SelectOrganization("org-a", false); err != nil {
t.Fatalf("SelectOrganization returned error: %v", err)
}

session := &oauth.Session{
Version: oauth.SessionVersion,
Host: "buildkite.localhost",
ClientID: "buildkite-cli",
AccessToken: "bkua_access",
RefreshToken: "bkrt_refresh",
TokenType: "Bearer",
}

kr := keyring.New()
if err := kr.SetSession("org-a", session); err != nil {
t.Fatalf("SetSession org-a returned error: %v", err)
}
if err := kr.SetSession("org-b", session); err != nil {
t.Fatalf("SetSession org-b returned error: %v", err)
}

cmd := &LogoutCmd{Org: "org-a"}
f := &factory.Factory{Config: conf}
if err := cmd.logoutOrg(f); err != nil {
t.Fatalf("logoutOrg returned error: %v", err)
}

if _, err := kr.GetSession("org-a"); err == nil {
t.Fatal("expected org-a session to be deleted")
}
if _, err := kr.GetSession("org-b"); err == nil {
t.Fatal("expected org-b sibling session to be deleted")
}
}
Loading