diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 38a31582..3a0319fc 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -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) { @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 { diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index b3d6f1ef..8234dfd5 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -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() @@ -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) + } +} diff --git a/pkg/oauth/device_test.go b/pkg/oauth/device_test.go new file mode 100644 index 00000000..94a77c05 --- /dev/null +++ b/pkg/oauth/device_test.go @@ -0,0 +1,160 @@ +package oauth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestRequestDeviceAuthorization(t *testing.T) { + var sawRequest bool + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawRequest = true + + if r.Method != "POST" { + t.Errorf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/oauth/device_authorization" { + t.Errorf("path = %s, want /oauth/device_authorization", r.URL.Path) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + if got := r.FormValue("client_id"); got != "test-client" { + t.Errorf("client_id = %q, want test-client", got) + } + if got := r.FormValue("scope"); got != "read_user read_organizations" { + t.Errorf("scope = %q, want requested scopes", got) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(DeviceAuthorizationResponse{ + DeviceCode: "device-code", + UserCode: "ABCD-EFGH", + VerificationURI: "https://buildkite.example/oauth/device", + VerificationURIComplete: "https://buildkite.example/oauth/device/ABCD-EFGH", + ExpiresIn: 600, + Interval: 5, + }) + })) + defer server.Close() + + origTransport := http.DefaultTransport + http.DefaultTransport = server.Client().Transport + defer func() { http.DefaultTransport = origTransport }() + + deviceAuth, err := RequestDeviceAuthorization(context.Background(), &Config{ + Host: server.URL[len("https://"):], + ClientID: "test-client", + Scopes: "read_user read_organizations", + }) + if err != nil { + t.Fatalf("RequestDeviceAuthorization: %v", err) + } + if !sawRequest { + t.Fatal("server did not receive request") + } + if deviceAuth.DeviceCode != "device-code" { + t.Errorf("DeviceCode = %q, want device-code", deviceAuth.DeviceCode) + } + if deviceAuth.UserCode != "ABCD-EFGH" { + t.Errorf("UserCode = %q, want ABCD-EFGH", deviceAuth.UserCode) + } + if deviceAuth.VerificationURIComplete != "https://buildkite.example/oauth/device/ABCD-EFGH" { + t.Errorf("VerificationURIComplete = %q", deviceAuth.VerificationURIComplete) + } +} + +func TestRequestDeviceAuthorizationError(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid_scope","error_description":"Invalid scope"}`)) + })) + defer server.Close() + + origTransport := http.DefaultTransport + http.DefaultTransport = server.Client().Transport + defer func() { http.DefaultTransport = origTransport }() + + _, err := RequestDeviceAuthorization(context.Background(), &Config{ + Host: server.URL[len("https://"):], + ClientID: "test-client", + Scopes: "read_user hack_the_planet", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if got, want := err.Error(), "device authorization error: invalid_scope - Invalid scope"; got != want { + t.Fatalf("error = %q, want %q", got, want) + } +} + +func TestPollDeviceAccessTokenRetriesPendingAndSlowDown(t *testing.T) { + var requests int + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + + if r.URL.Path != "/oauth/token" { + t.Errorf("path = %s, want /oauth/token", r.URL.Path) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + if got := r.FormValue("grant_type"); got != deviceCodeGrantType { + t.Errorf("grant_type = %q, want %q", got, deviceCodeGrantType) + } + if got := r.FormValue("device_code"); got != "device-code" { + t.Errorf("device_code = %q, want device-code", got) + } + + w.Header().Set("Content-Type", "application/json") + switch requests { + case 1: + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"authorization_pending","error_description":"Device authorization is pending"}`)) + case 2: + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"slow_down","error_description":"Polling too quickly"}`)) + default: + w.Write([]byte(`{ + "access_token":"access-token", + "token_type":"Bearer", + "scope":"read_user", + "refresh_token":"refresh-token", + "expires_in":3600 + }`)) + } + })) + defer server.Close() + + origTransport := http.DefaultTransport + http.DefaultTransport = server.Client().Transport + defer func() { http.DefaultTransport = origTransport }() + + var sleeps []time.Duration + tokenResp, err := pollDeviceAccessToken( + context.Background(), + &Config{Host: server.URL[len("https://"):], ClientID: "test-client"}, + &DeviceAuthorizationResponse{DeviceCode: "device-code", Interval: 1}, + func(_ context.Context, duration time.Duration) error { + sleeps = append(sleeps, duration) + return nil + }, + ) + if err != nil { + t.Fatalf("pollDeviceAccessToken: %v", err) + } + if requests != 3 { + t.Fatalf("requests = %d, want 3", requests) + } + if tokenResp.AccessToken != "access-token" { + t.Errorf("AccessToken = %q, want access-token", tokenResp.AccessToken) + } + if len(sleeps) != 2 || sleeps[0] != time.Second || sleeps[1] != 6*time.Second { + t.Fatalf("sleeps = %v, want [1s 6s]", sleeps) + } +} diff --git a/pkg/oauth/oauth.go b/pkg/oauth/oauth.go index 11ed769d..d79aefb3 100644 --- a/pkg/oauth/oauth.go +++ b/pkg/oauth/oauth.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net" @@ -19,6 +20,10 @@ import ( const ( DefaultHost = "buildkite.com" + + deviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code" + defaultDeviceInterval = 5 * time.Second + deviceSlowDownIncrement = 5 * time.Second ) // AllScopes is the complete set of Buildkite API token scopes. When no --scopes @@ -164,6 +169,18 @@ type TokenResponse struct { ErrorDesc string `json:"error_description,omitempty"` } +// DeviceAuthorizationResponse holds the response from the device authorization endpoint. +type DeviceAuthorizationResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + Error string `json:"error,omitempty"` + ErrorDesc string `json:"error_description,omitempty"` +} + // Flow manages an OAuth authentication flow type Flow struct { config *Config @@ -174,20 +191,7 @@ type Flow struct { // NewFlow creates a new OAuth flow func NewFlow(cfg *Config) (*Flow, error) { - if cfg.Host == "" { - // Allow override via environment variable for local development - if envHost := os.Getenv("BUILDKITE_HOST"); envHost != "" { - cfg.Host = envHost - } else { - cfg.Host = DefaultHost - } - } - if cfg.ClientID == "" { - cfg.ClientID = DefaultClientID - } - if cfg.Scopes == "" { - cfg.Scopes = strings.Join(AllScopes, " ") - } + normalizeConfigDefaults(cfg) // Generate PKCE verifier and state codeVerifier, err := generateCodeVerifier() @@ -370,6 +374,160 @@ func (f *Flow) ExchangeCode(ctx context.Context, code string) (*TokenResponse, e return &tokenResp, nil } +// RequestDeviceAuthorization starts an OAuth device authorization flow. +func RequestDeviceAuthorization(ctx context.Context, cfg *Config) (*DeviceAuthorizationResponse, error) { + normalizeConfigDefaults(cfg) + + deviceURL := fmt.Sprintf("https://%s/oauth/device_authorization", cfg.Host) + data := url.Values{ + "client_id": {cfg.ClientID}, + "scope": {cfg.Scopes}, + } + + req, err := http.NewRequestWithContext(ctx, "POST", deviceURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("device authorization request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var deviceResp DeviceAuthorizationResponse + if err := json.Unmarshal(body, &deviceResp); err != nil { + return nil, fmt.Errorf("failed to parse device authorization response: %w", err) + } + + if deviceResp.Error != "" { + return nil, fmt.Errorf("device authorization error: %s - %s", deviceResp.Error, deviceResp.ErrorDesc) + } + + if deviceResp.DeviceCode == "" || deviceResp.UserCode == "" || deviceResp.VerificationURI == "" { + return nil, fmt.Errorf("incomplete device authorization response") + } + + return &deviceResp, nil +} + +// PollDeviceAccessToken polls the OAuth token endpoint until the user authorizes the device code. +func PollDeviceAccessToken(ctx context.Context, cfg *Config, deviceAuth *DeviceAuthorizationResponse) (*TokenResponse, error) { + return pollDeviceAccessToken(ctx, cfg, deviceAuth, sleepContext) +} + +func pollDeviceAccessToken(ctx context.Context, cfg *Config, deviceAuth *DeviceAuthorizationResponse, sleep func(context.Context, time.Duration) error) (*TokenResponse, error) { + normalizeConfigDefaults(cfg) + + interval := time.Duration(deviceAuth.Interval) * time.Second + if interval <= 0 { + interval = defaultDeviceInterval + } + + for { + tokenResp, err := exchangeDeviceCode(ctx, cfg, deviceAuth.DeviceCode) + if err == nil { + return tokenResp, nil + } + + var tokenErr *tokenError + if !errors.As(err, &tokenErr) { + return nil, err + } + + switch tokenErr.ErrorCode { + case "authorization_pending": + if err := sleep(ctx, interval); err != nil { + return nil, err + } + case "slow_down": + interval += deviceSlowDownIncrement + if err := sleep(ctx, interval); err != nil { + return nil, err + } + default: + return nil, err + } + } +} + +type tokenError struct { + ErrorCode string + Description string +} + +func (e *tokenError) Error() string { + if e.Description == "" { + return fmt.Sprintf("token error: %s", e.ErrorCode) + } + return fmt.Sprintf("token error: %s - %s", e.ErrorCode, e.Description) +} + +func exchangeDeviceCode(ctx context.Context, cfg *Config, deviceCode string) (*TokenResponse, error) { + tokenURL := fmt.Sprintf("https://%s/oauth/token", cfg.Host) + data := url.Values{ + "grant_type": {deviceCodeGrantType}, + "client_id": {cfg.ClientID}, + "device_code": {deviceCode}, + } + + req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("device token request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var tokenResp TokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to parse device token response: %w", err) + } + + if tokenResp.Error != "" { + return nil, &tokenError{ErrorCode: tokenResp.Error, Description: tokenResp.ErrorDesc} + } + + if tokenResp.AccessToken == "" { + return nil, fmt.Errorf("no access token in device token response") + } + + return &tokenResp, nil +} + +func sleepContext(ctx context.Context, duration time.Duration) error { + timer := time.NewTimer(duration) + defer timer.Stop() + + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + // RefreshAccessToken exchanges a refresh token for a new access token and refresh token. func RefreshAccessToken(ctx context.Context, host, clientID, refreshToken string) (*TokenResponse, error) { if host == "" { @@ -458,3 +616,19 @@ func generateState() (string, error) { } return base64.RawURLEncoding.EncodeToString(b), nil } + +func normalizeConfigDefaults(cfg *Config) { + if cfg.Host == "" { + if envHost := os.Getenv("BUILDKITE_HOST"); envHost != "" { + cfg.Host = envHost + } else { + cfg.Host = DefaultHost + } + } + if cfg.ClientID == "" { + cfg.ClientID = DefaultClientID + } + if cfg.Scopes == "" { + cfg.Scopes = strings.Join(AllScopes, " ") + } +}