diff --git a/cmd/login/login.go b/cmd/login/login.go index 95163770..314d18c0 100644 --- a/cmd/login/login.go +++ b/cmd/login/login.go @@ -12,7 +12,6 @@ import ( "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/config" "github.com/launchdarkly/ldcli/internal/login" - "github.com/launchdarkly/ldcli/internal/output" ) func NewLoginCmd( @@ -66,7 +65,7 @@ func run(client login.Client) func(*cobra.Command, []string) error { viper.GetString(cliflags.BaseURIFlag), ) if err != nil { - return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) + return err } var b strings.Builder @@ -79,9 +78,21 @@ func run(client login.Client) func(*cobra.Command, []string) error { deviceAuthorization.VerificationURI, ), ) - fmt.Fprintln(cmd.OutOrStdout(), b.String()) + deviceAuthorizationToken, err := login.FetchToken( + client, + deviceAuthorization.DeviceCode, + viper.GetString(cliflags.BaseURIFlag), + login.TokenInterval, + login.MaxFetchTokenAttempts, + ) + if err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Your token is %s\n", deviceAuthorizationToken.AccessToken) + return nil } } diff --git a/internal/login/login.go b/internal/login/login.go index 4b135411..8f145272 100644 --- a/internal/login/login.go +++ b/internal/login/login.go @@ -7,11 +7,16 @@ import ( "io" "net/http" "os" + "time" "github.com/launchdarkly/ldcli/internal/errors" ) -const ClientID = "e6506150369268abae3ed46152687201" +const ( + ClientID = "e6506150369268abae3ed46152687201" + MaxFetchTokenAttempts = 120 // two minutes assuming interval is one second + TokenInterval = 1 * time.Second +) type DeviceAuthorization struct { DeviceCode string `json:"deviceCode"` @@ -71,6 +76,8 @@ func (c Client) MakeRequest( return body, nil } +// FetchDeviceAuthorization makes a request to create a device authorization that will later be +// used to set a local access token if the user grants access. func FetchDeviceAuthorization( client UnauthenticatedClient, clientID string, @@ -100,19 +107,62 @@ func FetchDeviceAuthorization( return deviceAuthorization, nil } +// FetchToken attempts to get an access token. It will continue to try while the user logs in to +// verify their request. If the user denies the request or does nothing long enough for this call +// to time out, we do not return an access token. func FetchToken( client UnauthenticatedClient, deviceCode string, baseURI string, + interval time.Duration, + maxAttempts int, +) (DeviceAuthorizationToken, error) { + var attempts int + for { + if attempts > maxAttempts { + return DeviceAuthorizationToken{}, errors.NewError("The request timed out after too many attempts.") + } + deviceAuthorizationToken, err := fetchToken( + client, + deviceCode, + baseURI, + ) + if err == nil { + return deviceAuthorizationToken, nil + } + + var e struct { + Code string `json:"code"` + Message string `json:"message"` + } + err = json.Unmarshal([]byte(err.Error()), &e) + if err != nil { + return DeviceAuthorizationToken{}, errors.NewErrorWrapped("error reading response", err) + } + switch e.Code { + case "authorization_pending": + attempts += 1 + case "access_denied": + return DeviceAuthorizationToken{}, errors.NewError("Your request has been denied.") + case "expired_token": + return DeviceAuthorizationToken{}, errors.NewError("Your request has expired. Please try logging in again.") + default: + return DeviceAuthorizationToken{}, errors.NewErrorWrapped("We cannot complete your request.", err) + } + time.Sleep(interval) + } +} + +func fetchToken( + client UnauthenticatedClient, + deviceCode string, + baseURI string, ) (DeviceAuthorizationToken, error) { path := fmt.Sprintf("%s/internal/device-authorization/token", baseURI) - body := fmt.Sprintf( - `{ - "deviceCode": %q - }`, - deviceCode, - ) - res, err := client.MakeRequest("POST", path, []byte(body)) + body, _ := json.Marshal(map[string]string{ + "deviceCode": deviceCode, + }) + res, err := client.MakeRequest("POST", path, body) if err != nil { return DeviceAuthorizationToken{}, err } diff --git a/internal/login/login_test.go b/internal/login/login_test.go index a258453a..0a94be4f 100644 --- a/internal/login/login_test.go +++ b/internal/login/login_test.go @@ -1,8 +1,11 @@ package login_test import ( + "encoding/json" "testing" + "time" + "github.com/launchdarkly/ldcli/internal/errors" "github.com/launchdarkly/ldcli/internal/login" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -61,28 +64,87 @@ func TestFetchDeviceAuthorization(t *testing.T) { } func TestFetchToken(t *testing.T) { - baseURI := "http://test.com" - mockClient := mockClient{} - mockClient.On( - "MakeRequest", - "POST", - "http://test.com/internal/device-authorization/token", - []byte(`{ - "deviceCode": "test-device-code" - }`), - ).Return([]byte(`{ - "accessToken": "test-access-token" - }`), nil) - expected := login.DeviceAuthorizationToken{ - AccessToken: "test-access-token", + t.Run("with a token response", func(t *testing.T) { + minimalDuration := 1 * time.Microsecond + minimalAttempts := 1 + input, _ := json.Marshal(map[string]string{ + "deviceCode": "test-device-code", + }) + output, _ := json.Marshal(map[string]string{ + "accessToken": "test-access-token", + }) + mockClient := mockClient{} + mockClient.On( + "MakeRequest", + "POST", + "http://test.com/internal/device-authorization/token", + input, + ).Return(output, nil) + + result, err := login.FetchToken( + &mockClient, + "test-device-code", + "http://test.com", + minimalDuration, + minimalAttempts, + ) + + require.NoError(t, err) + assert.Equal(t, "test-access-token", result.AccessToken) + }) +} + +func TestFetchToken_WithError(t *testing.T) { + tests := map[string]struct { + errCode string + expectedErr string + }{ + "with an authorization pending response": { + errCode: "authorization_pending", + expectedErr: "The request timed out after too many attempts.", + }, + "with an access denied response": { + errCode: "access_denied", + expectedErr: "Your request has been denied.", + }, + "with an expired token response": { + errCode: "expired_token", + expectedErr: "Your request has expired. Please try logging in again.", + }, + "with an error response": { + errCode: "error_code", + expectedErr: "We cannot complete your request.", + }, } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + minimalDuration := 1 * time.Microsecond + minimalAttempts := 1 + input, _ := json.Marshal(map[string]string{ + "deviceCode": "test-device-code", + }) + output, _ := json.Marshal(map[string]string{ + "code": tt.errCode, + "message": "error message", + }) + responseErr := errors.NewError(string(output)) + mockClient := mockClient{} + mockClient.On( + "MakeRequest", + "POST", + "http://test.com/internal/device-authorization/token", + input, + ).Return([]byte(""), responseErr) - result, err := login.FetchToken( - &mockClient, - "test-device-code", - baseURI, - ) + _, err := login.FetchToken( + &mockClient, + "test-device-code", + "http://test.com", + minimalDuration, + minimalAttempts, + ) - require.NoError(t, err) - assert.Equal(t, expected, result) + assert.EqualError(t, err, tt.expectedErr) + }) + } }