Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
23 changes: 23 additions & 0 deletions cli/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package cli

import "github.com/spf13/cobra"

func makeOperationAuthCmd() (*cobra.Command, error) {
cmd := &cobra.Command{
Use: "auth",
Short: "Inspect and manage your authentication state",
}
statusCmd, err := makeOperationAuthStatusCmd()
if err != nil {
return nil, err
}
cmd.AddCommand(statusCmd)

logoutCmd, err := makeOperationAuthLogoutCmd()
if err != nil {
return nil, err
}
cmd.AddCommand(logoutCmd)

return cmd, nil
}
98 changes: 98 additions & 0 deletions cli/auth_login.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package cli

import (
"context"
"errors"
"fmt"

"github.com/latitudesh/lsh/internal/authclient"
"github.com/latitudesh/lsh/internal/config"
"github.com/latitudesh/lsh/internal/version"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

func makeOperationLoginCmd() (*cobra.Command, error) {
cmd := &cobra.Command{
Use: "login [DEPRECATED: api-token]",
Short: "Authenticate with Latitude",
Long: `Without arguments, opens your browser to authorize this CLI through the
Latitude dashboard and saves the resulting credential locally.

Pass --with-token <T> to skip the browser flow and use an existing token.

A positional <api-token> argument is still accepted for backward
compatibility but is deprecated and will be removed in a future release.`,
Args: cobra.MaximumNArgs(1),
RunE: runOperationLogin,
}
cmd.Flags().String("with-token", "", "use an existing API token instead of the browser flow")
cmd.Flags().String("profile", "", "save credentials under this profile name (default: team slug)")
return cmd, nil
}

func runOperationLogin(cmd *cobra.Command, args []string) error {
token, _ := cmd.Flags().GetString("with-token")
profileName, _ := cmd.Flags().GetString("profile")

if len(args) == 1 {
if token != "" {
return errors.New("cannot combine positional token with --with-token")
}
fmt.Fprintln(cmd.ErrOrStderr(),
"warning: passing the token as a positional argument is deprecated; use --with-token instead")
token = args[0]
}

ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}

client := newAuthClient()

if token != "" {
return loginWithToken(ctx, client, token, profileName)
}
return loginViaBrowser(ctx, client, profileName)
}

// newAuthClient builds an authclient using the current hostname/scheme
// from viper. This is the same configuration the generated SDK uses,
// so dev overrides (--hostname / --scheme) flow through naturally.
func newAuthClient() *authclient.Client {
hostname := viper.GetString("hostname")
if hostname == "" {
hostname = "api.latitude.sh"
}
scheme := viper.GetString("scheme")
if scheme == "" {
scheme = "https"
}
ua := fmt.Sprintf("lsh/%s", version.Version)
return authclient.New(scheme+"://"+hostname, ua)
}

// saveProfile resolves the profile name (override > team slug > "default")
// and upserts it in the config file. SetProfile promotes it to the default
// only when no default exists yet, so logging in to add a second profile
// (e.g. another team) does not silently change the active context — use
// `lsh profile use` to switch explicitly.
func saveProfile(override string, p config.Profile) (string, error) {
f, err := config.Load()
if err != nil {
return "", err
}
name := override
if name == "" {
name = p.TeamSlug
}
if name == "" {
name = "default"
}
f.SetProfile(name, p)
if err := config.Save(f); err != nil {
return "", err
}
return name, nil
}
171 changes: 171 additions & 0 deletions cli/auth_login_browser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package cli

import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"time"

"github.com/latitudesh/lsh/internal/authclient"
"github.com/latitudesh/lsh/internal/browser"
"github.com/latitudesh/lsh/internal/config"
"github.com/latitudesh/lsh/internal/version"
)

const (
pollInterval = 2 * time.Second
pollMaxBackoff = 5 * time.Second
loginTimeout = 5*time.Minute + 30*time.Second // a touch over the API TTL

// Window after CreateSession during which a 404 from the poll
// endpoint is treated as "not yet visible" instead of "expired".
// Covers initial Redis propagation and the gap between the create
// returning and the dashboard/CLI being able to read the session.
earlyNotFoundGrace = 15 * time.Second
)

func loginViaBrowser(ctx context.Context, client *authclient.Client, profileOverride string) error {
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()

session, err := client.CreateSession(ctx, authclient.CreateSessionRequest{
ClientName: "lsh",
ClientVersion: version.Version,
})
if err != nil {
return fmt.Errorf("could not start login session: %w", err)
}

headless := browser.LooksHeadless()
printAuthorizePrompt(session, headless)

if !headless {
if err := browser.Open(session.AuthorizeURL); err != nil {
fmt.Fprintln(os.Stderr, "Could not open browser automatically; please open the URL above manually.")
}
}

approved, err := pollUntilApproved(ctx, client, session.ID, session.Secret)
if err != nil {
return err
}

if approved.APIKey == nil || approved.Team == nil || approved.User == nil {
return errors.New("login session approved but the credential payload was incomplete")
}

apiVersion := os.Getenv("LATITUDE_API_VERSION")
if apiVersion == "" {
apiVersion = "2023-06-01"
}

profile := config.Profile{
Authorization: approved.APIKey.Token,
KeyID: approved.APIKey.ID,
KeyName: approved.APIKey.Name,
TeamID: approved.Team.ID,
TeamName: approved.Team.Name,
TeamSlug: approved.Team.Slug,
Email: approved.User.Email,
Source: config.SourceBrowser,
APIVersion: apiVersion,
}

name, err := saveProfile(profileOverride, profile)
if err != nil {
return fmt.Errorf("could not save profile: %w", err)
}

fmt.Printf("\n✅ Logged in as %s on team %s (profile: %s)\n", profile.Email, profile.TeamName, name)
return nil
}

func printAuthorizePrompt(session *authclient.Session, headless bool) {
fmt.Println("Opening your browser to authorize this CLI...")
fmt.Println()
fmt.Println(" URL:")
fmt.Printf(" %s\n", session.AuthorizeURL)
fmt.Println()
fmt.Println(" Confirm this code matches what your browser shows:")
fmt.Printf(" %s\n", session.UserCode)
if headless {
fmt.Println()
fmt.Println(" (detected headless environment — open the URL above on a machine with a browser)")
}
fmt.Println()
fmt.Println("Waiting for approval... press Ctrl+C to cancel.")
}

// pollUntilApproved retries GET /auth/cli_sessions/<id> until the
// session is approved, terminally errored, or the deadline expires.
func pollUntilApproved(ctx context.Context, client *authclient.Client, id, secret string) (*authclient.Session, error) {
start := time.Now()
deadline := start.Add(loginTimeout)
interval := pollInterval

for {
if ctx.Err() != nil {
return nil, ctx.Err()
}
session, err := client.PollSession(ctx, id, secret)
if err == nil {
// A successful response means the server is healthy; reset the
// interval so a previous transient error doesn't keep us polling
// at the backed-off rate for the rest of the session.
interval = pollInterval
if session.Status == "approved" {
if session.APIKey != nil {
return session, nil
}
// Approval and key are written together server-side, so an
// approved session with no key is anomalous — fail fast
// instead of polling silently until the deadline.
return nil, errors.New("login was approved but no API key was returned; please run `lsh login` again")
}
// status=pending → keep polling
Comment thread
LanusseMorais marked this conversation as resolved.
} else {
var httpErr *authclient.HTTPError
if errors.As(err, &httpErr) {
switch httpErr.StatusCode {
case 404:
// 404 right after CreateSession just means the session
// has not propagated yet — retry within the grace
// window before treating it as terminal.
if time.Since(start) >= earlyNotFoundGrace {
return nil, errors.New("login session expired or was cancelled")
}
case 410:
return nil, errors.New("login session has already been used; please run `lsh login` again")
case 401:
return nil, errors.New("login session secret rejected (this should not happen)")
default:
// transient — back off and retry until deadline
}
} else {
// network error — back off and retry
}
interval = nextBackoff(interval)
}

if time.Now().After(deadline) {
return nil, errors.New("login session expired before approval")
}

select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(interval):
}
}
}

func nextBackoff(current time.Duration) time.Duration {
doubled := current * 2
if doubled > pollMaxBackoff {
return pollMaxBackoff
}
return doubled
}

60 changes: 60 additions & 0 deletions cli/auth_login_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package cli

import (
"context"
"errors"
"fmt"
"os"

"github.com/latitudesh/lsh/internal/authclient"
"github.com/latitudesh/lsh/internal/config"
)

// loginWithToken validates an existing API token by calling
// GET /user/profile and persists it under a profile.
func loginWithToken(ctx context.Context, client *authclient.Client, token, profileOverride string) error {
if token == "" {
return errors.New("--with-token requires a non-empty value")
}

profileResp, err := client.GetUserProfile(ctx, token)
if err != nil {
var httpErr *authclient.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == 401 || httpErr.StatusCode == 403) {
return errors.New("token rejected by the API — check the value and try again")
}
return fmt.Errorf("could not validate token: %w", err)
}

// The user profile payload doesn't include the team. Fetch it
// separately — GET /team is scoped to the token's membership
// server-side, so it returns exactly the team this token is bound to.
team, err := client.GetCurrentTeam(ctx, token)
if err != nil {
return fmt.Errorf("could not fetch team for this token: %w", err)
}

apiVersion := os.Getenv("LATITUDE_API_VERSION")
if apiVersion == "" {
apiVersion = "2023-06-01"
}
profile := config.Profile{
Authorization: token,
Email: profileResp.Email,
Source: config.SourceWithToken,
APIVersion: apiVersion,
}
Comment thread
LanusseMorais marked this conversation as resolved.
if team != nil {
profile.TeamID = team.ID
profile.TeamName = team.Name
profile.TeamSlug = team.Slug
}

name, err := saveProfile(profileOverride, profile)
if err != nil {
return fmt.Errorf("could not save profile: %w", err)
}

fmt.Printf("✅ Logged in as %s on team %s (profile: %s)\n", profile.Email, profile.TeamName, name)
return nil
}
Loading