diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 1b20d914..a6533bd5 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -59,8 +59,8 @@ 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 to a specific organization on a headless machine or remote shell + $ bk auth login --device --org my-org # Login on a headless Linux host using an in-memory /dev/shm credential store $ bk auth login --device --credential-store shm @@ -293,9 +293,6 @@ func (c *LoginCmd) validate(kongCtx *kong.Context) 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 } @@ -312,8 +309,11 @@ func (c *LoginCmd) credentialStoreFlagProvided(kongCtx *kong.Context) bool { } func (c *LoginCmd) runDeviceLogin(ctx context.Context, f *factory.Factory, resolvedScopes string, credentialStore oauthTokenStore) error { + orgSlug, orgUUID := organizationIdentifier(c.Org) cfg := &oauth.Config{ ClientID: oauth.DefaultClientID, + OrgSlug: orgSlug, + OrgUUID: orgUUID, Scopes: resolvedScopes, } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 5400322c..19308a8e 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -256,9 +256,8 @@ func TestLoginCmdValidateDeviceIncompatibleFlags(t *testing.T) { 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 with org", + cmd: LoginCmd{Device: true, Org: "buildkite"}, }, { name: "device only", @@ -499,6 +498,12 @@ func TestLoginCmdRunDeviceFlow(t *testing.T) { if got := r.FormValue("scope"); got != "read_user read_organizations" { t.Errorf("scope = %q, want requested scopes", got) } + if got := r.FormValue("organization"); got != "test-org" { + t.Errorf("organization = %q, want test-org", got) + } + if got := r.FormValue("organization_uuid"); got != "" { + t.Errorf("organization_uuid = %q, want empty", got) + } _ = json.NewEncoder(w).Encode(oauth.DeviceAuthorizationResponse{ DeviceCode: "device-code", UserCode: "ABCD-EFGH", @@ -547,7 +552,7 @@ func TestLoginCmdRunDeviceFlow(t *testing.T) { 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"} + cmd := &LoginCmd{Device: true, Org: "test-org", Scopes: "read_user read_organizations"} if err := cmd.Run(nil, authStubGlobals{}); err != nil { t.Fatalf("Run() error = %v", err) } diff --git a/pkg/oauth/device_test.go b/pkg/oauth/device_test.go index d8771471..eb42634e 100644 --- a/pkg/oauth/device_test.go +++ b/pkg/oauth/device_test.go @@ -25,61 +25,85 @@ func (rt *failingRoundTripper) RoundTrip(req *http.Request) (*http.Response, err } func TestRequestDeviceAuthorization(t *testing.T) { - var sawRequest bool - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sawRequest = true + tests := []struct { + name string + orgSlug string + orgUUID string + wantOrganization string + wantUUID string + }{ + {name: "without organization"}, + {name: "with organization slug", orgSlug: "buildkite", wantOrganization: "buildkite"}, + {name: "with organization UUID", orgSlug: "ignored", orgUUID: "018f2f7e-7e99-7d77-b4d3-a95cb01805f4", wantUUID: "018f2f7e-7e99-7d77-b4d3-a95cb01805f4"}, + } - 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) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var sawRequest bool + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawRequest = true - 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() + 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) + } + if got := r.FormValue("organization"); got != tt.wantOrganization { + t.Errorf("organization = %q, want %q", got, tt.wantOrganization) + } + if got := r.FormValue("organization_uuid"); got != tt.wantUUID { + t.Errorf("organization_uuid = %q, want %q", got, tt.wantUUID) + } - origTransport := http.DefaultTransport - http.DefaultTransport = server.Client().Transport - defer func() { http.DefaultTransport = origTransport }() + 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() - 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) + 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", + OrgSlug: tt.orgSlug, + OrgUUID: tt.orgUUID, + }) + 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) + } + }) } } diff --git a/pkg/oauth/oauth.go b/pkg/oauth/oauth.go index dc17fcbf..18a0265e 100644 --- a/pkg/oauth/oauth.go +++ b/pkg/oauth/oauth.go @@ -383,6 +383,11 @@ func RequestDeviceAuthorization(ctx context.Context, cfg *Config) (*DeviceAuthor "client_id": {cfg.ClientID}, "scope": {cfg.Scopes}, } + if cfg.OrgUUID != "" { + data.Set("organization_uuid", cfg.OrgUUID) + } else if cfg.OrgSlug != "" { + data.Set("organization", cfg.OrgSlug) + } req, err := http.NewRequestWithContext(ctx, "POST", deviceURL, strings.NewReader(data.Encode())) if err != nil {