Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions pkg/auth/token_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package auth
import (
"encoding/json"
"fmt"
"strconv"
"time"

"github.com/zalando/go-keyring"
Expand All @@ -15,21 +16,32 @@ const (
keyringUser = "oauth-token"
)

// StoredToken represents the persisted OAuth tokens.
// StoredToken represents the persisted OAuth tokens and the identity of the
// authenticated user.
type StoredToken struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"`
Scope string `json:"scope,omitempty"`
UserID string `json:"user_id,omitempty"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
}

// IsExpired returns true if the access token has expired (with a 60s buffer).
func (t *StoredToken) IsExpired() bool {
return time.Now().Unix() >= t.ExpiresAt-60
}

// SaveToken persists tokens from an OAuthTokenResponse to the OS keychain.
// SaveToken persists tokens (and the user identity, when present) from an
// OAuthTokenResponse to the OS keychain.
func SaveToken(resp *dashboard.OAuthTokenResponse) error {
return persistToken(storedTokenFromResponse(resp))
}

// storedTokenFromResponse builds a StoredToken from an OAuth token response,
// including the user identity when the response carries a user object.
func storedTokenFromResponse(resp *dashboard.OAuthTokenResponse) StoredToken {
expiresAt := resp.CreatedAt + int64(resp.ExpiresIn)
if expiresAt == 0 {
expiresAt = time.Now().Unix() + int64(resp.ExpiresIn)
Expand All @@ -42,6 +54,17 @@ func SaveToken(resp *dashboard.OAuthTokenResponse) error {
Scope: resp.Scope,
}

if resp.User != nil && resp.User.ID != 0 {
stored.UserID = strconv.Itoa(resp.User.ID)
stored.Email = resp.User.Email
stored.Name = resp.User.Name
}

return stored
}

// persistToken marshals and writes a StoredToken to the OS keychain.
func persistToken(stored StoredToken) error {
data, err := json.Marshal(stored)
if err != nil {
return err
Expand Down Expand Up @@ -96,9 +119,14 @@ func GetValidToken(client *dashboard.Client) (string, error) {
return "", fmt.Errorf("session expired and refresh failed — run `algolia auth login` to re-authenticate: %w", err)
}

if err := SaveToken(tokenResp); err != nil {
return tokenResp.AccessToken, nil
refreshed := storedTokenFromResponse(tokenResp)
if refreshed.UserID == "" {
refreshed.UserID = stored.UserID
refreshed.Email = stored.Email
refreshed.Name = stored.Name
}

return tokenResp.AccessToken, nil
_ = persistToken(refreshed)

return refreshed.AccessToken, nil
}
140 changes: 140 additions & 0 deletions pkg/auth/token_store_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package auth

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

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

"github.com/algolia/cli/api/dashboard"
)

func TestSaveToken_PersistsUserIdentity(t *testing.T) {
keyring.MockInit()

resp := &dashboard.OAuthTokenResponse{
AccessToken: "access-token",
RefreshToken: "refresh-token",
ExpiresIn: 7200,
CreatedAt: time.Now().Unix(),
Scope: "scope:test",
User: &dashboard.User{
ID: 42,
Email: "user@test.com",
Name: "Test User",
},
}

require.NoError(t, SaveToken(resp))

stored := LoadToken()
require.NotNil(t, stored)
assert.Equal(t, "access-token", stored.AccessToken)
assert.Equal(t, "42", stored.UserID)
assert.Equal(t, "user@test.com", stored.Email)
assert.Equal(t, "Test User", stored.Name)
}

func TestSaveToken_WithoutUserLeavesIdentityEmpty(t *testing.T) {
keyring.MockInit()

require.NoError(t, SaveToken(&dashboard.OAuthTokenResponse{
AccessToken: "access-token",
ExpiresIn: 7200,
CreatedAt: time.Now().Unix(),
}))

stored := LoadToken()
require.NotNil(t, stored)
assert.Empty(t, stored.UserID)
assert.Empty(t, stored.Email)
assert.Empty(t, stored.Name)
}

// newRefreshServer returns a dashboard client whose token endpoint replies with
// the given response, mimicking POST /2/oauth/token for grant_type=refresh_token.
func newRefreshServer(t *testing.T, resp dashboard.OAuthTokenResponse) *dashboard.Client {
t.Helper()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
require.NoError(t, json.NewEncoder(w).Encode(resp))
}))
t.Cleanup(srv.Close)

client := dashboard.NewClientWithHTTPClient("test-client-id", srv.Client())
client.DashboardURL = srv.URL
return client
}

func TestGetValidToken_PreservesIdentityWhenRefreshOmitsUser(t *testing.T) {
keyring.MockInit()

// An existing session that already knows its user, but whose access token
// has expired so a refresh is required.
require.NoError(t, persistToken(StoredToken{
AccessToken: "old-access",
RefreshToken: "refresh-1",
ExpiresAt: time.Now().Unix() - 60,
UserID: "42",
Email: "user@test.com",
Name: "Test User",
}))

client := newRefreshServer(t, dashboard.OAuthTokenResponse{
AccessToken: "new-access",
RefreshToken: "refresh-2",
ExpiresIn: 7200,
CreatedAt: time.Now().Unix(),
})

token, err := GetValidToken(client)
require.NoError(t, err)
assert.Equal(t, "new-access", token)

stored := LoadToken()
require.NotNil(t, stored)
assert.Equal(t, "new-access", stored.AccessToken)
assert.Equal(t, "refresh-2", stored.RefreshToken)
// Identity survives a refresh response that doesn't echo the user back.
assert.Equal(t, "42", stored.UserID)
assert.Equal(t, "user@test.com", stored.Email)
assert.Equal(t, "Test User", stored.Name)
}

func TestGetValidToken_SelfHealsIdentityFromRefresh(t *testing.T) {
keyring.MockInit()

// A session created before identity was persisted: no user fields yet.
require.NoError(t, persistToken(StoredToken{
AccessToken: "old-access",
RefreshToken: "refresh-1",
ExpiresAt: time.Now().Unix() - 60,
}))

client := newRefreshServer(t, dashboard.OAuthTokenResponse{
AccessToken: "new-access",
RefreshToken: "refresh-2",
ExpiresIn: 7200,
CreatedAt: time.Now().Unix(),
User: &dashboard.User{
ID: 7,
Email: "healed@test.com",
Name: "Healed User",
},
})

_, err := GetValidToken(client)
require.NoError(t, err)

stored := LoadToken()
require.NotNil(t, stored)
// Identity is back-filled from the refresh response.
assert.Equal(t, "7", stored.UserID)
assert.Equal(t, "healed@test.com", stored.Email)
assert.Equal(t, "Healed User", stored.Name)
}
38 changes: 34 additions & 4 deletions pkg/cmd/auth/login/login.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package login

import (
"context"
"fmt"

"github.com/AlecAivazis/survey/v2"
Expand All @@ -14,6 +15,7 @@ import (
"github.com/algolia/cli/pkg/config"
"github.com/algolia/cli/pkg/iostreams"
"github.com/algolia/cli/pkg/prompt"
"github.com/algolia/cli/pkg/telemetry"
"github.com/algolia/cli/pkg/validators"
)

Expand Down Expand Up @@ -71,7 +73,7 @@ func NewLoginCmd(f *cmdutil.Factory) *cobra.Command {
`),
Args: validators.NoArgs(),
RunE: func(cmd *cobra.Command, args []string) error {
return runLoginCmd(opts)
return runLoginCmd(cmd.Context(), opts)
},
}

Expand All @@ -83,13 +85,13 @@ func NewLoginCmd(f *cmdutil.Factory) *cobra.Command {
return cmd
}

func runLoginCmd(opts *LoginOptions) error {
return RunOAuthFlow(opts, false)
func runLoginCmd(ctx context.Context, opts *LoginOptions) error {
return RunOAuthFlow(ctx, opts, false)
}

// 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(opts *LoginOptions, signup bool) error {
func RunOAuthFlow(ctx context.Context, opts *LoginOptions, signup bool) error {
cs := opts.IO.ColorScheme()
client := opts.NewDashboardClient(auth.OAuthClientID())

Expand All @@ -99,6 +101,8 @@ func RunOAuthFlow(opts *LoginOptions, signup bool) error {
return err
}

identifyAuthenticatedUser(ctx)

opts.IO.StartProgressIndicatorWithLabel("Fetching applications")
apps, err := client.ListApplications(accessToken)
opts.IO.StopProgressIndicator()
Expand Down Expand Up @@ -138,6 +142,32 @@ func RunOAuthFlow(opts *LoginOptions, signup bool) error {
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)
}
}

// applyStoredIdentity copies the persisted user identity from the stored token
// onto the request's telemetry metadata. It reports whether an identity was
// applied.
func applyStoredIdentity(ctx context.Context) bool {
token := auth.LoadToken()
if token == nil || token.UserID == "" {
return false
}

metadata := telemetry.GetEventMetadata(ctx)
if metadata == nil {
return false
}

metadata.SetUser(token.UserID, token.Email, token.Name)
return true
}

// reuseExistingAPIKey checks if a local profile already has an API key for
// the given application. If so, it sets app.APIKey and returns true.
func reuseExistingAPIKey(cfg config.IConfig, app *dashboard.Application) bool {
Expand Down
59 changes: 59 additions & 0 deletions pkg/cmd/auth/login/login_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package login

import (
"context"
"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/cmdutil"
"github.com/algolia/cli/pkg/iostreams"
"github.com/algolia/cli/pkg/telemetry"
"github.com/algolia/cli/test"
)

Expand Down Expand Up @@ -71,6 +75,61 @@ func TestSelectApplication_ByName_NotFound(t *testing.T) {
assert.Contains(t, err.Error(), "not found")
}

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

err := auth.SaveToken(&dashboard.OAuthTokenResponse{
AccessToken: "access",
RefreshToken: "refresh",
ExpiresIn: 3600,
User: &dashboard.User{
ID: 42,
Email: "user@example.com",
Name: "Ada Lovelace",
},
})
require.NoError(t, err)

metadata := telemetry.NewEventMetadata()
ctx := telemetry.WithEventMetadata(context.Background(), metadata)

assert.True(t, applyStoredIdentity(ctx))
assert.Equal(t, "42", metadata.UserID)
assert.Equal(t, "user@example.com", metadata.Email)
assert.Equal(t, "Ada Lovelace", metadata.Name)
}

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

metadata := telemetry.NewEventMetadata()
ctx := telemetry.WithEventMetadata(context.Background(), metadata)

assert.False(t, applyStoredIdentity(ctx))
assert.Empty(t, metadata.UserID)
}

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

// Token persisted before identity was tracked (no user object).
err := auth.SaveToken(&dashboard.OAuthTokenResponse{
AccessToken: "access",
RefreshToken: "refresh",
ExpiresIn: 3600,
})
require.NoError(t, err)

metadata := telemetry.NewEventMetadata()
ctx := telemetry.WithEventMetadata(context.Background(), metadata)

assert.False(t, applyStoredIdentity(ctx))
assert.Empty(t, metadata.UserID)
}

func TestSelectApplication_MultipleApps_NonInteractive_NoAppName(t *testing.T) {
io, _, _, _ := iostreams.Test()
opts := &LoginOptions{IO: io}
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/auth/signup/signup.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func NewSignupCmd(f *cmdutil.Factory) *cobra.Command {
`),
Args: validators.NoArgs(),
RunE: func(cmd *cobra.Command, args []string) error {
return login.RunOAuthFlow(opts, true)
return login.RunOAuthFlow(cmd.Context(), opts, true)
},
}

Expand Down
Loading
Loading