Skip to content

Commit

Permalink
feat: sc-240964/fetch token (#326)
Browse files Browse the repository at this point in the history
Can fetch token and show various responses

When a user runs the login command, we poll a token endpoint while they log in to LD. Once that happens, we respond with a new access token or an error, depending on if they allow or deny the login request.
  • Loading branch information
dbolson authored Jun 24, 2024
1 parent 769b925 commit 58dc226
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 32 deletions.
17 changes: 14 additions & 3 deletions cmd/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
66 changes: 58 additions & 8 deletions internal/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
104 changes: 83 additions & 21 deletions internal/login/login_test.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
})
}
}

0 comments on commit 58dc226

Please sign in to comment.