Skip to content
Merged
8 changes: 6 additions & 2 deletions pkg/auth/authenticate.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ func EnsureAuthenticated(
cs := io.ColorScheme()
fmt.Fprintf(io.Out, "%s %s\n", cs.WarningIcon(), err)

return RunOAuth(io, client, false, true)
// No flow tracker: this re-authentication belongs to the calling flow,
// not to an `auth login` funnel.
return RunOAuth(io, client, false, true, nil)
}

// ReauthenticateIfExpired checks if err is a session-expired error from the API.
Expand All @@ -41,5 +43,7 @@ func ReauthenticateIfExpired(
ClearToken()
fmt.Fprintf(io.Out, "%s Session expired.\n", cs.WarningIcon())

return RunOAuth(io, client, false, true)
// No flow tracker: this re-authentication belongs to the calling flow,
// not to an `auth login` funnel.
return RunOAuth(io, client, false, true, nil)
}
13 changes: 12 additions & 1 deletion pkg/auth/oauth_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/algolia/cli/api/dashboard"
"github.com/algolia/cli/pkg/iostreams"
"github.com/algolia/cli/pkg/telemetry"
)

// DefaultOAuthClientID is a public OAuth client ID (PKCE flow, not a secret).
Expand Down Expand Up @@ -35,7 +36,15 @@ func OAuthClientID() string {
// launched, e.g. SSH / containers).
//
// If signup is true the browser opens to the sign-up page.
func RunOAuth(io *iostreams.IOStreams, client *dashboard.Client, signup, openBrowser bool) (string, error) {
//
// The optional tracker (nil-safe) records which step the flow is in, so the
// telemetry of the calling flow can tell where the user stopped.
func RunOAuth(
io *iostreams.IOStreams,
client *dashboard.Client,
signup, openBrowser bool,
tracker *telemetry.FlowTracker,
) (string, error) {
cs := io.ColorScheme()

redirectURI, resultCh, err := StartCallbackServer()
Expand Down Expand Up @@ -69,6 +78,7 @@ func RunOAuth(io *iostreams.IOStreams, client *dashboard.Client, signup, openBro
}

fmt.Fprintf(io.Out, "Waiting for authentication...\n")
tracker.SetStep(telemetry.StepBrowserWait)
cbResult := <-resultCh

if cbResult.Error != "" {
Expand All @@ -78,6 +88,7 @@ func RunOAuth(io *iostreams.IOStreams, client *dashboard.Client, signup, openBro
return "", fmt.Errorf("no authorization code received")
}

tracker.SetStep(telemetry.StepCodeExchange)
io.StartProgressIndicatorWithLabel("Exchanging code for tokens")
tokenResp, err := client.AuthorizationCodeGrant(cbResult.Code, codeVerifier, redirectURI)
io.StopProgressIndicator()
Expand Down
51 changes: 48 additions & 3 deletions pkg/cmd/auth/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,54 @@ func runLoginCmd(ctx context.Context, opts *LoginOptions) error {
// RunOAuthFlow runs the full browser-based OAuth + profile setup flow.
// If signup is true, the browser opens to the sign-up page instead of sign-in.
func RunOAuthFlow(ctx context.Context, opts *LoginOptions, signup bool) error {
flow := telemetry.FlowLogin
if signup {
flow = telemetry.FlowSignup
}
tracker := telemetry.NewFlowTracker()
telemetry.TrackEvent(ctx, telemetry.AuthStarted(flow, opts.NoBrowser))

err := runOAuthFlowSteps(ctx, opts, signup, tracker)
trackOAuthFlowOutcome(ctx, flow, tracker, err)
return err
}

// trackOAuthFlowOutcome reports how the auth flow ended: completed, aborted by
// the user, or failed.
func trackOAuthFlowOutcome(
ctx context.Context,
flow telemetry.Flow,
tracker *telemetry.FlowTracker,
err error,
) {
switch {
case err == nil:
telemetry.TrackEvent(ctx, telemetry.AuthCompleted(flow, tracker))
case cmdutil.IsUserCancellation(err):
telemetry.TrackEvent(ctx, telemetry.AuthAborted(flow, tracker))
default:
telemetry.TrackEvent(ctx, telemetry.AuthFailed(flow, tracker, err))
}
}

func runOAuthFlowSteps(
ctx context.Context,
opts *LoginOptions,
signup bool,
tracker *telemetry.FlowTracker,
) error {
cs := opts.IO.ColorScheme()
client := opts.NewDashboardClient(auth.OAuthClientID())

openBrowser := !opts.NoBrowser
accessToken, err := auth.RunOAuth(opts.IO, client, signup, openBrowser)
accessToken, err := auth.RunOAuth(opts.IO, client, signup, openBrowser, tracker)
if err != nil {
return err
}

identifyAuthenticatedUser(ctx)

tracker.SetStep(telemetry.StepAppsFetch)
opts.IO.StartProgressIndicatorWithLabel("Fetching applications")
apps, err := client.ListApplications(accessToken)
opts.IO.StopProgressIndicator()
Expand All @@ -115,11 +152,13 @@ func RunOAuthFlow(ctx context.Context, opts *LoginOptions, signup bool) error {
if len(apps) == 0 {
fmt.Fprintf(opts.IO.Out, "\n%s No applications found. Let's create one.\n", cs.WarningIcon())

tracker.SetStep(telemetry.StepAppCreate)
appDetails, err = apputil.CreateAndFetchApplication(opts.IO, client, accessToken, "", opts.AppName)
if err != nil {
return err
}
} else {
tracker.SetStep(telemetry.StepAppSelect)
interactive := opts.IO.CanPrompt()
app, err := selectApplication(opts, apps, interactive)
if err != nil {
Expand All @@ -139,15 +178,21 @@ func RunOAuthFlow(ctx context.Context, opts *LoginOptions, signup bool) error {
profileName = appDetails.Name
}

tracker.SetStep(telemetry.StepProfileConfigure)
return apputil.ConfigureProfile(opts.IO, opts.Config, appDetails, profileName, opts.Default)
}

// identifyAuthenticatedUser emits a telemetry Identify for the user that just
// authenticated. It is a no-op when no identified token is present.
func identifyAuthenticatedUser(ctx context.Context) {
if applyStoredIdentity(ctx) {
telemetry.IdentifyOnce(ctx)
if !applyStoredIdentity(ctx) {
return
}
client := telemetry.GetTelemetryClient(ctx)
if client == nil {
return
}
_ = client.Identify(ctx)
}

// applyStoredIdentity copies the persisted user identity from the stored token
Expand Down
48 changes: 48 additions & 0 deletions pkg/cmd/auth/login/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package login

import (
"context"
"errors"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -13,6 +14,7 @@ import (
"github.com/algolia/cli/pkg/cmdutil"
"github.com/algolia/cli/pkg/iostreams"
"github.com/algolia/cli/pkg/telemetry"
"github.com/algolia/cli/pkg/telemetry/telemetrytest"
"github.com/algolia/cli/test"
)

Expand Down Expand Up @@ -130,6 +132,52 @@ func TestApplyStoredIdentity_TokenWithoutIdentityReturnsFalse(t *testing.T) {
assert.Empty(t, metadata.UserID)
}

func TestTrackOAuthFlowOutcome(t *testing.T) {
tests := []struct {
name string
err error
wantEvent string
wantStep bool
}{
{
name: "success",
err: nil,
wantEvent: telemetry.EventAuthCompleted,
},
{
name: "user cancellation",
err: cmdutil.ErrCancel,
wantEvent: telemetry.EventAuthAborted,
wantStep: true,
},
{
name: "failure",
err: errors.New("boom"),
wantEvent: telemetry.EventAuthFailed,
wantStep: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &telemetrytest.RecordingClient{}
ctx := telemetry.WithTelemetryClient(context.Background(), client)
tracker := telemetry.NewFlowTracker()
tracker.SetStep(telemetry.StepAppsFetch)

trackOAuthFlowOutcome(ctx, telemetry.FlowLogin, tracker, tt.err)

require.Len(t, client.Events, 1)
event := client.Events[0]
assert.Equal(t, tt.wantEvent, event.Name)
assert.Equal(t, telemetry.FlowLogin, event.Properties["flow"])
if tt.wantStep {
assert.Equal(t, telemetry.StepAppsFetch, event.Properties["step"])
}
})
}
}

func TestSelectApplication_MultipleApps_NonInteractive_NoAppName(t *testing.T) {
io, _, _, _ := iostreams.Test()
opts := &LoginOptions{IO: io}
Expand Down
10 changes: 8 additions & 2 deletions pkg/cmd/auth/logout/logout.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package logout

import (
"context"
"fmt"

"github.com/MakeNowJust/heredoc"
Expand All @@ -10,6 +11,7 @@ import (
"github.com/algolia/cli/pkg/auth"
"github.com/algolia/cli/pkg/cmdutil"
"github.com/algolia/cli/pkg/iostreams"
"github.com/algolia/cli/pkg/telemetry"
"github.com/algolia/cli/pkg/validators"
)

Expand Down Expand Up @@ -38,14 +40,14 @@ func NewLogoutCmd(f *cmdutil.Factory) *cobra.Command {
`),
Args: validators.NoArgs(),
RunE: func(cmd *cobra.Command, args []string) error {
return runLogoutCmd(opts)
return runLogoutCmd(cmd.Context(), opts)
},
}

return cmd
}

func runLogoutCmd(opts *LogoutOptions) error {
func runLogoutCmd(ctx context.Context, opts *LogoutOptions) error {
cs := opts.IO.ColorScheme()
stored := auth.LoadToken()

Expand All @@ -68,6 +70,10 @@ func runLogoutCmd(opts *LogoutOptions) error {
}
}

// Tracked before clearing the local state, while the user identifier is
// still attached to the telemetry metadata.
telemetry.TrackEvent(ctx, telemetry.AuthLogout())

auth.ClearToken()

fmt.Fprintf(opts.IO.Out, "%s Signed out successfully.\n", cs.SuccessIcon())
Expand Down
73 changes: 73 additions & 0 deletions pkg/cmd/auth/logout/logout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package logout

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zalando/go-keyring"

"github.com/algolia/cli/api/dashboard"
"github.com/algolia/cli/pkg/auth"
"github.com/algolia/cli/pkg/iostreams"
"github.com/algolia/cli/pkg/telemetry"
"github.com/algolia/cli/pkg/telemetry/telemetrytest"
)

func newLogoutOpts(srv *httptest.Server) *LogoutOptions {
io, _, _, _ := iostreams.Test()
return &LogoutOptions{
IO: io,
NewDashboardClient: func(string) *dashboard.Client {
c := dashboard.NewClientWithHTTPClient("test", srv.Client())
c.DashboardURL = srv.URL
return c
},
}
}

func TestLogout_EmitsAuthLogoutBeforeClearingToken(t *testing.T) {
keyring.MockInit()
t.Cleanup(auth.ClearToken)
require.NoError(t, auth.SaveToken(&dashboard.OAuthTokenResponse{
AccessToken: "access",
RefreshToken: "refresh",
ExpiresIn: 3600,
}))

mux := http.NewServeMux()
mux.HandleFunc("/2/oauth/revoke", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
srv := httptest.NewServer(mux)
defer srv.Close()

client := &telemetrytest.RecordingClient{}
ctx := telemetry.WithTelemetryClient(context.Background(), client)

require.NoError(t, runLogoutCmd(ctx, newLogoutOpts(srv)))

require.Len(t, client.Events, 1)
event := client.Events[0]
assert.Equal(t, telemetry.EventAuthLogout, event.Name)
assert.Equal(t, telemetry.FlowLogout, event.Properties["flow"])
// The token is cleared after the event was tracked.
assert.Nil(t, auth.LoadToken())
// No Identify at logout time: Segment cannot un-identify.
assert.Zero(t, client.Identifies)
}

func TestLogout_AlreadySignedOutEmitsNothing(t *testing.T) {
keyring.MockInit()
auth.ClearToken()

client := &telemetrytest.RecordingClient{}
ctx := telemetry.WithTelemetryClient(context.Background(), client)

require.NoError(t, runLogoutCmd(ctx, newLogoutOpts(nil)))

assert.Empty(t, client.Events)
}
Loading
Loading