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
96 changes: 87 additions & 9 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type LoginCmd struct {
Scopes string `help:"OAuth scopes to request" default:""`
Org string `help:"Organization slug or UUID to request access for" optional:""`
Token string `help:"API token to store (non-OAuth login, requires --org)" optional:""`
Device bool `help:"Authenticate using OAuth device authorization instead of opening a browser callback" optional:""`
}

func organizationIdentifier(org string) (orgSlug, orgUUID string) {
Expand Down Expand Up @@ -57,6 +58,9 @@ Examples:
# Login non-interactively with an API token
$ bk auth login --org my-org --token my-token

# Login on a headless machine or remote shell
$ bk auth login --device

# Login with read-only access
$ bk auth login --scopes read_only

Expand Down Expand Up @@ -107,6 +111,10 @@ func (c *LoginCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
return err
}

if err := c.validate(); err != nil {
return err
}

if c.Token != "" {
if err := LoginWithToken(f, c.Org, c.Token); err != nil {
return err
Expand All @@ -117,10 +125,13 @@ func (c *LoginCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
}

// 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.
// When --scopes is empty, the OAuth package requests the default scope set.
resolvedScopes := oauth.ResolveScopes(c.Scopes)

if c.Device {
return c.runDeviceLogin(context.Background(), f, resolvedScopes)
}

orgSlug, orgUUID := organizationIdentifier(c.Org)

// Create OAuth flow
Expand Down Expand Up @@ -168,27 +179,92 @@ func (c *LoginCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
return fmt.Errorf("token exchange failed: %w", err)
}

org, err := completeOAuthLogin(ctx, f, tokenResp)
if err != nil {
return err
}

printOAuthLoginSuccess(org, tokenResp)

return nil
}

func (c *LoginCmd) validate() error {
if c.Device && c.Token != "" {
return errors.New("--device cannot be used with --token")
}
if c.Device && c.Org != "" {
return errors.New("--org is not supported with --device; choose an organization on the authorization page")
}
return nil
}

func (c *LoginCmd) runDeviceLogin(ctx context.Context, f *factory.Factory, resolvedScopes string) error {
cfg := &oauth.Config{
ClientID: oauth.DefaultClientID,
Scopes: resolvedScopes,
}

deviceAuth, err := oauth.RequestDeviceAuthorization(ctx, cfg)
if err != nil {
return fmt.Errorf("failed to start device authorization: %w", err)
}

verificationURL := deviceAuth.VerificationURIComplete
if verificationURL == "" {
verificationURL = deviceAuth.VerificationURI
}

fmt.Println("Visit this URL to authorize this device:")
fmt.Printf(" %s\n\n", verificationURL)
fmt.Println("Code:")
fmt.Printf(" %s\n\n", deviceAuth.UserCode)
fmt.Println("Waiting for authorization...")

timeout := time.Duration(deviceAuth.ExpiresIn) * time.Second
if timeout <= 0 {
timeout = 10 * time.Minute
}
pollCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

tokenResp, err := oauth.PollDeviceAccessToken(pollCtx, cfg, deviceAuth)
if err != nil {
return fmt.Errorf("device authorization failed: %w", err)
}

org, err := completeOAuthLogin(ctx, f, tokenResp)
if err != nil {
return err
}

printOAuthLoginSuccess(org, tokenResp)

return nil
}

func completeOAuthLogin(ctx context.Context, f *factory.Factory, tokenResp *oauth.TokenResponse) (string, error) {
// Resolve org from the API using the new token
client, err := buildkite.NewOpts(
buildkite.WithTokenAuth(tokenResp.AccessToken),
buildkite.WithBaseURL(f.Config.RESTAPIEndpoint()),
)
if err != nil {
return fmt.Errorf("failed to create API client: %w", err)
return "", fmt.Errorf("failed to create API client: %w", err)
}

orgs, _, err := client.Organizations.List(ctx, nil)
if err != nil {
return fmt.Errorf("failed to list organizations: %w", err)
return "", fmt.Errorf("failed to list organizations: %w", err)
}
if len(orgs) == 0 {
return fmt.Errorf("no organizations found for this token")
return "", fmt.Errorf("no organizations found for this token")
}

org := orgs[0]

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

// Store refresh token if the server issued one
Expand All @@ -201,13 +277,15 @@ func (c *LoginCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
}
}

fmt.Printf("\n✅ Successfully authenticated with organization %q\n", org.Slug)
return org.Slug, nil
}

func printOAuthLoginSuccess(org string, tokenResp *oauth.TokenResponse) {
fmt.Printf("\n✅ Successfully authenticated with organization %q\n", org)
fmt.Printf(" Scopes: %s\n", tokenResp.Scope)
if tokenResp.RefreshToken != "" {
fmt.Printf(" Token expires in: %s (will refresh automatically)\n", formatDuration(tokenResp.ExpiresIn))
}

return nil
}

func formatDuration(seconds int) string {
Expand Down
195 changes: 194 additions & 1 deletion cmd/auth/login_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
package auth

import "testing"
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/buildkite/cli/v3/pkg/keyring"
"github.com/buildkite/cli/v3/pkg/oauth"
buildkite "github.com/buildkite/go-buildkite/v4"
)

type authStubGlobals struct{}

func (authStubGlobals) SkipConfirmation() bool { return false }
func (authStubGlobals) DisableInput() bool { return false }
func (authStubGlobals) IsQuiet() bool { return false }
func (authStubGlobals) DisablePager() bool { return false }
func (authStubGlobals) EnableDebug() bool { return false }

func TestOrganizationIdentifier(t *testing.T) {
t.Parallel()
Expand Down Expand Up @@ -48,3 +68,176 @@ func TestOrganizationIdentifier(t *testing.T) {
})
}
}

func TestLoginCmdValidateDeviceIncompatibleFlags(t *testing.T) {
t.Parallel()

tests := []struct {
name string
cmd LoginCmd
wantErr string
}{
{
name: "device with token",
cmd: LoginCmd{Device: true, Token: "token", Org: "buildkite"},
wantErr: "--device cannot be used with --token",
},
{
name: "device with org",
cmd: LoginCmd{Device: true, Org: "buildkite"},
wantErr: "--org is not supported with --device; choose an organization on the authorization page",
},
{
name: "device only",
cmd: LoginCmd{Device: true},
},
{
name: "token with org",
cmd: LoginCmd{Token: "token", Org: "buildkite"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

err := tt.cmd.validate()
if tt.wantErr == "" {
if err != nil {
t.Fatalf("validate() error = %v, want nil", err)
}
return
}
if err == nil {
t.Fatal("validate() error = nil, want error")
}
if got := err.Error(); got != tt.wantErr {
t.Fatalf("validate() error = %q, want %q", got, tt.wantErr)
}
})
}
}

func TestLoginCmdRunDeviceFlow(t *testing.T) {
t.Chdir(t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("BUILDKITE_API_TOKEN", "")
t.Setenv("BUILDKITE_ORGANIZATION_SLUG", "")

keyring.MockForTesting()
t.Cleanup(keyring.ResetForTesting)

var sawDeviceAuthorization bool
var sawTokenExchange bool
var sawOrganizationsList bool

server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

switch r.URL.Path {
case "/oauth/device_authorization":
sawDeviceAuthorization = true
if r.Method != "POST" {
t.Errorf("device authorization method = %s, want POST", r.Method)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
if got := r.FormValue("client_id"); got != oauth.DefaultClientID {
t.Errorf("client_id = %q, want %q", got, oauth.DefaultClientID)
}
if got := r.FormValue("scope"); got != "read_user read_organizations" {
t.Errorf("scope = %q, want requested scopes", got)
}
_ = json.NewEncoder(w).Encode(oauth.DeviceAuthorizationResponse{
DeviceCode: "device-code",
UserCode: "ABCD-EFGH",
VerificationURI: "https://buildkite.example/device",
VerificationURIComplete: "https://buildkite.example/device/ABCD-EFGH",
ExpiresIn: 600,
Interval: 1,
})
case "/oauth/token":
sawTokenExchange = true
if r.Method != "POST" {
t.Errorf("token method = %s, want POST", r.Method)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
if got := r.FormValue("grant_type"); got != "urn:ietf:params:oauth:grant-type:device_code" {
t.Errorf("grant_type = %q, want device code grant", got)
}
if got := r.FormValue("device_code"); got != "device-code" {
t.Errorf("device_code = %q, want device-code", got)
}
_ = json.NewEncoder(w).Encode(oauth.TokenResponse{
AccessToken: "access-token",
TokenType: "Bearer",
Scope: "read_user read_organizations",
RefreshToken: "refresh-token",
ExpiresIn: 3600,
})
case "/v2/organizations":
sawOrganizationsList = true
if got := r.Header.Get("Authorization"); got != "Bearer access-token" {
t.Errorf("Authorization = %q, want Bearer access-token", got)
}
_ = json.NewEncoder(w).Encode([]buildkite.Organization{{Slug: "test-org"}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()

origTransport := http.DefaultTransport
http.DefaultTransport = server.Client().Transport
t.Cleanup(func() { http.DefaultTransport = origTransport })

t.Setenv("BUILDKITE_HOST", strings.TrimPrefix(server.URL, "https://"))
t.Setenv("BUILDKITE_REST_API_ENDPOINT", server.URL)

cmd := &LoginCmd{Device: true, Scopes: "read_user read_organizations"}
if err := cmd.Run(nil, authStubGlobals{}); err != nil {
t.Fatalf("Run() error = %v", err)
}

if !sawDeviceAuthorization {
t.Fatal("device authorization endpoint was not called")
}
if !sawTokenExchange {
t.Fatal("token endpoint was not called")
}
if !sawOrganizationsList {
t.Fatal("organizations endpoint was not called")
}

kr := keyring.New()
token, err := kr.Get("test-org")
if err != nil {
t.Fatalf("Get token: %v", err)
}
if token != "access-token" {
t.Fatalf("stored token = %q, want access-token", token)
}

refreshToken, err := kr.GetRefreshToken("test-org")
if err != nil {
t.Fatalf("GetRefreshToken: %v", err)
}
if refreshToken != "refresh-token" {
t.Fatalf("stored refresh token = %q, want refresh-token", refreshToken)
}

configBytes, err := os.ReadFile(filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "bk.yaml"))
if err != nil {
t.Fatalf("read config: %v", err)
}
config := string(configBytes)
if !strings.Contains(config, "selected_org: test-org") {
t.Fatalf("config did not select test-org:\n%s", config)
}
if !strings.Contains(config, "test-org:") {
t.Fatalf("config did not register test-org:\n%s", config)
}
}
Loading