Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
2559dfd
feat(session): add Store interface, FileStore, MemStore, Session struct
miguelsanchez-upsun Apr 3, 2026
9815224
fix(session): simplify FileStore.Delete, document MemStore concurrency
miguelsanchez-upsun Apr 3, 2026
a2df8c6
feat(session): add session ID resolver
miguelsanchez-upsun Apr 3, 2026
56b1a9d
fix(session): collapse consecutive invalid chars in sanitiseID to mat…
miguelsanchez-upsun Apr 3, 2026
16d71c4
feat(session): add Manager
miguelsanchez-upsun Apr 3, 2026
ae795db
fix(session): add MkdirAll to Store interface, fix DeleteAll error sw…
miguelsanchez-upsun Apr 3, 2026
4b15bbb
feat(auth): add PKCE helpers (RFC 7636)
miguelsanchez-upsun Apr 3, 2026
f177685
feat(auth): add sessionTokenSource backed by session.Manager
miguelsanchez-upsun Apr 3, 2026
4ba6b3b
fix(auth): wrap load error in unsafeRefreshToken
miguelsanchez-upsun Apr 3, 2026
3ece677
feat(auth): replace NewLegacyCLIClient with NewClient backed by sessi…
miguelsanchez-upsun Apr 3, 2026
22da051
fix(init): rename legacyCLIClient to authClient
miguelsanchez-upsun Apr 3, 2026
9142946
test(mockapi): add phone verification endpoints
miguelsanchez-upsun Apr 3, 2026
7352117
test(mockapi): add PKCE authorize, refresh_token, revoke endpoints
miguelsanchez-upsun Apr 3, 2026
a7c0e7f
fix(mockapi): remove /v1/ prefix from phone verification routes
miguelsanchez-upsun Apr 3, 2026
068dbc5
test(integration): extend auth:info baseline tests
miguelsanchez-upsun Apr 3, 2026
70e729e
test(integration): fix dead code and improve NoAutoLogin test debugga…
miguelsanchez-upsun Apr 3, 2026
05e321a
test(integration): add auth:logout baseline tests
miguelsanchez-upsun Apr 3, 2026
1ee36b4
test(integration): add auth:token baseline tests
miguelsanchez-upsun Apr 3, 2026
8fe33cc
test(integration): add auth:api-token-login baseline tests
miguelsanchez-upsun Apr 3, 2026
981fa5f
test(integration): add auth:browser-login baseline tests
miguelsanchez-upsun Apr 3, 2026
5edfda3
test(integration): add auth:verify-phone-number baseline tests
miguelsanchez-upsun Apr 3, 2026
140b846
test(integration): add session:switch baseline tests
miguelsanchez-upsun Apr 3, 2026
a157ad8
fix(mockapi): remove incorrect phone-verification routes; use actual …
miguelsanchez-upsun Apr 3, 2026
ddee460
feat(auth): implement auth:info command in Go
miguelsanchez-upsun Apr 3, 2026
12250fe
feat(auth): implement auth:token command in Go
miguelsanchez-upsun Apr 3, 2026
9d24a05
fix(auth): resolve API base URL and API token from env vars in newAPI…
miguelsanchez-upsun Apr 3, 2026
1319fa6
feat(auth): implement auth:logout command in Go
miguelsanchez-upsun Apr 3, 2026
cbddcab
feat(auth): implement auth:api-token-login command in Go
miguelsanchez-upsun Apr 3, 2026
735d0fc
feat(auth): implement auth:browser-login with native PKCE flow
miguelsanchez-upsun Apr 3, 2026
1f9a26f
feat(auth): implement auth:verify-phone-number command in Go
miguelsanchez-upsun Apr 3, 2026
6fe0f54
feat(session): implement session:switch command in Go
miguelsanchez-upsun Apr 3, 2026
6cf8378
feat(ux): match PHP-style help output for Go auth commands
miguelsanchez-upsun Apr 3, 2026
aabc326
chore: promote spf13/pflag to direct dependency
miguelsanchez-upsun Apr 3, 2026
426eb03
fix(auth): use correct env var names matching PHP CLI (AUTH_URL, API_…
miguelsanchez-upsun Apr 3, 2026
917cc71
fix(auth): inject token into PHP delegation; fix SSH finalization and…
miguelsanchez-upsun Apr 7, 2026
eba466a
test(auth): add regression test for PHP command auth after Go login
miguelsanchez-upsun Apr 7, 2026
7a32f67
feat(auth): align Go commands with PHP logic parity
miguelsanchez-upsun Apr 7, 2026
7d409d5
refactor(auth): consolidate OAuth2 URL resolution into internal/auth/…
miguelsanchez-upsun Apr 7, 2026
8c36c0b
refactor(auth): consolidate api token exchange into single exchangeAP…
miguelsanchez-upsun Apr 7, 2026
8f2fca7
fix(auth): thread context into token refresh; drop unused refreshToke…
miguelsanchez-upsun Apr 7, 2026
1edbc4b
fix(auth): replace http.DefaultClient with injectable http client fields
miguelsanchez-upsun Apr 7, 2026
ac9e5ca
fix(auth): remove misleading error return from delegateSSHFinalization
miguelsanchez-upsun Apr 7, 2026
bd36870
fix(auth): check token expiry in browser-login already-logged-in guard
miguelsanchez-upsun Apr 7, 2026
31a9b08
fix(session): warn instead of silently swallow ResolveSessionID error…
miguelsanchez-upsun Apr 7, 2026
78360e9
fix(auth): use unicode.IsDigit for phone verification code validation
miguelsanchez-upsun Apr 7, 2026
2e94893
refactor(auth): simplify and deduplicate auth/session helpers
miguelsanchez-upsun Apr 7, 2026
533b759
test(auth): add integration tests for decline-relogin and logout --other
miguelsanchez-upsun Apr 7, 2026
26b287c
test(auth): add unit tests for URL resolution, session sanitisation, …
miguelsanchez-upsun Apr 7, 2026
2c4a613
fix(auth): align verify-phone method labels and logout message with P…
miguelsanchez-upsun Apr 7, 2026
02c5b78
feat(auth): retry api-token-login up to 5 times on invalid token (PHP…
miguelsanchez-upsun Apr 8, 2026
4003348
fix(auth): PHP parity for decline-relogin exit code, logout wording, …
miguelsanchez-upsun Apr 8, 2026
9486773
fix(auth): cap verify-phone retries at 5 attempts to match PHP setMax…
miguelsanchez-upsun Apr 8, 2026
ae7ddb3
fix(auth): restore POST body on 401 retry using GetBody callback
miguelsanchez-upsun Apr 8, 2026
62fafcc
feat(auth): add re-login prompt to auth:info on expired/missing session
miguelsanchez-upsun Apr 8, 2026
191b06c
feat(auth): auto-accept browser login on auth:info when --yes is passed
miguelsanchez-upsun Apr 8, 2026
e335db6
chore(lint): fix all golangci-lint issues
miguelsanchez-upsun Apr 13, 2026
effa719
fix(auth): address PR review comments on Store abstraction and transport
miguelsanchez-upsun Apr 13, 2026
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
96 changes: 96 additions & 0 deletions commands/auth/api_token_login.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package auth

import (
"bufio"
"errors"
"fmt"
"os"
"strings"

"github.com/spf13/cobra"

cobrahelp "github.com/upsun/cli/commands/cobrahelp"
"github.com/upsun/cli/internal/config"
"github.com/upsun/cli/internal/session"
)

func NewAPITokenLoginCommand(cfg *config.Config) *cobra.Command {
cmd := &cobra.Command{
Use: "auth:api-token-login",
Short: "Log in using an API token",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Block if TOKEN env var is already set via config.
if os.Getenv(cfg.Application.EnvPrefix+"TOKEN") != "" {
fmt.Fprintln(cmd.ErrOrStderr(), "An API token is already set via config")
return fmt.Errorf("an API token is already set via config")
}
// Non-interactive guard only when no arg (stdin would be needed).
if len(args) == 0 && os.Getenv(cfg.Application.EnvPrefix+"NO_INTERACTION") != "" {
fmt.Fprintln(cmd.ErrOrStderr(), "Non-interactive use of this command is not supported.")
return fmt.Errorf("non-interactive use of this command is not supported")
}

var (
apiToken string
s *session.Session
)
if len(args) > 0 {
apiToken = strings.TrimSpace(args[0])
var err error
s, err = exchangeAPIToken(cmd.Context(), cfg, apiToken)
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
} else {
const maxAttempts = 5
scanner := bufio.NewScanner(cmd.InOrStdin())
for attempt := 1; attempt <= maxAttempts; attempt++ {
fmt.Fprint(cmd.ErrOrStderr(), "Enter your API token: ")
if !scanner.Scan() {
return fmt.Errorf("read API token: %w", scanner.Err())
}
apiToken = strings.TrimSpace(scanner.Text())
if apiToken == "" {
fmt.Fprintln(cmd.ErrOrStderr(), "The token cannot be empty")
continue
}
var err error
s, err = exchangeAPIToken(cmd.Context(), cfg, apiToken)
if err == nil {
break
}
if errors.Is(err, ErrInvalidAPIToken) {
fmt.Fprintln(cmd.ErrOrStderr(), ErrInvalidAPIToken.Error())
if attempt == maxAttempts {
return fmt.Errorf("login failed after %d attempts", maxAttempts)
}
continue
}
return fmt.Errorf("login failed: %w", err)
}
}
fmt.Fprintln(cmd.ErrOrStderr(), "The API token is valid.")
Comment on lines +46 to +73
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the interactive path, empty input does not fail after maxAttempts; the loop can exit with apiToken == "" and s == nil, but the code still prints “The API token is valid.” and proceeds to persist credentials. Track whether a successful exchange occurred and return an error if attempts are exhausted due to empty input (and also handle the case where the loop completes without setting s).

Copilot uses AI. Check for mistakes.

mgr, err := session.New(cfg)
if err != nil {
return err
}
if err := mgr.SetAPIToken(apiToken); err != nil {
return err
}
if err := mgr.Save(s); err != nil {
return err
}

fmt.Fprintln(cmd.ErrOrStderr(), "You are logged in.")
if err := printUserInfo(cmd.Context(), mgr, cfg, cmd.ErrOrStderr()); err != nil {
return err
}
delegateSSHFinalization(cmd.Context(), cfg, cmd)
return nil
},
}
cobrahelp.SetPhpStyle(cmd)
return cmd
}
122 changes: 122 additions & 0 deletions commands/auth/browser_login.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// commands/auth/browser_login.go
package auth

import (
"bufio"
"fmt"
"os"
"strings"
"time"

"github.com/spf13/cobra"

cobrahelp "github.com/upsun/cli/commands/cobrahelp"
internalauth "github.com/upsun/cli/internal/auth"
"github.com/upsun/cli/internal/config"
"github.com/upsun/cli/internal/session"
)

func NewBrowserLoginCommand(cfg *config.Config) *cobra.Command {
var (
force bool
methods []string
maxAge int
)
cmd := &cobra.Command{
Use: "auth:browser-login",
Aliases: []string{"login"},
Short: "Log in via a browser",
RunE: func(cmd *cobra.Command, _ []string) error {
// If an API token is configured, browser login is not applicable.
if apiToken := os.Getenv(cfg.Application.EnvPrefix + "TOKEN"); apiToken != "" {
return fmt.Errorf("cannot log in via the browser while an API token is set (%sTOKEN)", cfg.Application.EnvPrefix)
}

mgr, err := session.New(cfg)
if err != nil {
return err
}

// Also check for an API token in the session.
if storedToken, err := mgr.GetAPIToken(); err == nil && storedToken != "" {
return fmt.Errorf("cannot log in via the browser while an API token is configured")
}

// Non-interactive guard.
if os.Getenv(cfg.Application.EnvPrefix+"NO_INTERACTION") != "" {
fmt.Fprintln(cmd.ErrOrStderr(), "Non-interactive use of this command is not supported.")
return fmt.Errorf("non-interactive use of this command is not supported")
}

printSessionID(cmd.ErrOrStderr(), cfg, mgr)

hasMaxAge := cmd.Flags().Changed("max-age")

// Check if already logged in (unless --force).
if !force && len(methods) == 0 && !hasMaxAge {
s, err := mgr.Load()
if err == nil && s != nil && s.AccessToken != "" && time.Now().Unix() < s.Expires {
fmt.Fprintf(cmd.ErrOrStderr(), "You are already logged in as a user.\n")
fmt.Fprint(cmd.ErrOrStderr(), "Log in anyway? [y/N] ")
scanner := bufio.NewScanner(cmd.InOrStdin())
scanner.Scan()
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
if answer != "y" && answer != "yes" {
return fmt.Errorf("login canceled")
}
force = true
}
}

flow := internalauth.NewBrowserFlow(cfg)
opts := internalauth.BrowserFlowOptions{
Force: force,
Methods: methods,
Stderr: cmd.ErrOrStderr(),
OnCodeReceived: func() {
fmt.Fprintln(cmd.ErrOrStderr(), "Login information received. Verifying...")
},
}
if hasMaxAge {
opts.MaxAge = &maxAge
}

fmt.Fprintf(cmd.ErrOrStderr(),
"\nHelp:\n Leave this command running during login.\n If you need to quit, use Ctrl+C.\n\n")

s, err := flow.Run(cmd.Context(), opts)
if err != nil {
return err
}

if err := mgr.Save(s); err != nil {
return err
}

if s.RefreshToken == "" {
clientID := cfg.API.OAuth2ClientID
fmt.Fprintln(cmd.ErrOrStderr(), "")
fmt.Fprintln(cmd.ErrOrStderr(), "Warning:")
fmt.Fprintln(cmd.ErrOrStderr(), "No refresh token is available. This will cause frequent login errors.")
fmt.Fprintln(cmd.ErrOrStderr(), "Please contact support.")
fmt.Fprintf(cmd.ErrOrStderr(),
"For internal use: the OAuth 2 client is probably misconfigured (client ID: %s).\n",
clientID)
}

fmt.Fprintln(cmd.ErrOrStderr(), "You are logged in.")

if err := printUserInfo(cmd.Context(), mgr, cfg, cmd.ErrOrStderr()); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not retrieve user info: %v\n", err)
}

delegateSSHFinalization(cmd.Context(), cfg, cmd)
return nil
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "Log in again, even if already logged in")
cmd.Flags().StringArrayVar(&methods, "method", nil, "Require specific authentication method(s)")
cmd.Flags().IntVar(&maxAge, "max-age", 0, "Maximum age (seconds) of the web authentication session")
cobrahelp.SetPhpStyle(cmd)
return cmd
}
156 changes: 156 additions & 0 deletions commands/auth/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package auth

import (
"context"
"fmt"
"io"
"net/http"
"os"
"strings"

"golang.org/x/oauth2"

"github.com/upsun/cli/internal/api"
internalauth "github.com/upsun/cli/internal/auth"
"github.com/upsun/cli/internal/config"
"github.com/upsun/cli/internal/legacy"
"github.com/upsun/cli/internal/session"
)

// InjectSessionCredentials injects stored credentials into a legacy PHP CLI wrapper so PHP
// can authenticate without relying on the OS credential helper (e.g. macOS Keychain).
// Respects an already-set TOKEN env var and is a no-op if session loading fails.
func InjectSessionCredentials(cfg *config.Config, wrapper *legacy.CLIWrapper) {
envPrefix := cfg.Application.EnvPrefix
if os.Getenv(envPrefix+"TOKEN") != "" {
return
}
mgr, err := session.New(cfg)
if err != nil {
return
}
if apiToken, err := mgr.GetAPIToken(); err == nil && apiToken != "" {
wrapper.ExtraEnv = append(wrapper.ExtraEnv, envPrefix+"TOKEN="+apiToken)
return
}
if s, err := mgr.Load(); err == nil && s != nil && s.AccessToken != "" {
wrapper.ExtraEnv = append(wrapper.ExtraEnv, envPrefix+"API_TOKEN="+s.AccessToken)
}
}

// httpClient is used for all outbound HTTP requests in this package.
// Can be replaced in tests to inject a custom transport.
var httpClient *http.Client = http.DefaultClient

// resolveBaseURL returns the API base URL, preferring the env var override.
func resolveBaseURL(cfg *config.Config) string {
if v := os.Getenv(cfg.Application.EnvPrefix + "API_URL"); v != "" {
return v
}
return cfg.API.BaseURL
}

// newAPIClient creates an authenticated API client for commands.
//
// Auth priority:
// 1. API token from env var ({EnvPrefix}TOKEN) or session storage — exchanged for OAuth access token.
// 2. Session OAuth token — used directly.
func newAPIClient(ctx context.Context, mgr *session.Manager, cfg *config.Config) (*api.Client, error) {
// Check for API token in env or session storage.
apiToken := os.Getenv(cfg.Application.EnvPrefix + "TOKEN")
if apiToken == "" {
var err error
apiToken, err = mgr.GetAPIToken()
if err != nil {
return nil, err
}
}

if apiToken != "" {
// Exchange the API token for an OAuth2 access token.
s, err := exchangeAPIToken(ctx, cfg, apiToken)
if err != nil {
return nil, err
}
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: s.AccessToken})
return api.NewClient(resolveBaseURL(cfg), oauth2.NewClient(ctx, ts))
}

// Fall back to session-based OAuth token source.
authClient, err := internalauth.NewClient(ctx, mgr, cfg)
if err != nil {
return nil, err
}
return api.NewClient(resolveBaseURL(cfg), authClient.HTTPClient)
}

// printSessionID prints the current session ID hint when the session is non-default or
// multiple sessions exist, followed by a blank line. No-ops otherwise.
func printSessionID(w io.Writer, cfg *config.Config, mgr *session.Manager) {
sessionID := mgr.SessionID()
ids, _ := mgr.List()
if sessionID == "default" && len(ids) <= 1 {
return
}
fmt.Fprintf(w, "The current session ID is: %s\n", sessionID)
if os.Getenv(cfg.Application.EnvPrefix+"SESSION_ID") == "" {
fmt.Fprintf(w, "Change this using: %s session:switch\n", cfg.Application.Executable)
}
fmt.Fprintln(w)
}

// printUserInfo fetches and prints the current user's info to w (used post-login).
func printUserInfo(ctx context.Context, mgr *session.Manager, cfg *config.Config, w io.Writer) error {
apiClient, err := newAPIClient(ctx, mgr, cfg)
if err != nil {
return err
}
info, err := apiClient.GetMyUser(ctx, false)
if err != nil {
return err
}
username, _ := info["username"].(string)
email, _ := info["email"].(string)
fmt.Fprintf(w, "Logged in as: %s (%s)\n", username, email)
return nil
}

// printTable writes a two-column property/value table to w.
func printTable(w io.Writer, properties []string, data map[string]interface{}) {
col1 := len("Property")
col2 := len("Value")
for _, p := range properties {
if len(p) > col1 {
col1 = len(p)
}
v := formatValue(data[p])
if len(v) > col2 {
col2 = len(v)
}
}
sep := "+" + strings.Repeat("-", col1+2) + "+" + strings.Repeat("-", col2+2) + "+"
fmt.Fprintln(w, sep)
fmt.Fprintf(w, "| %-*s | %-*s |\n", col1, "Property", col2, "Value")
fmt.Fprintln(w, sep)
for _, p := range properties {
v := formatValue(data[p])
fmt.Fprintf(w, "| %-*s | %-*s |\n", col1, p, col2, v)
}
fmt.Fprintln(w, sep)
}

// formatValue converts an interface{} to a display string.
func formatValue(v interface{}) string {
if v == nil {
return ""
}
switch val := v.(type) {
case bool:
if val {
return "true"
}
return "false"
default:
return strings.TrimSpace(fmt.Sprintf("%v", val))
}
}
Loading
Loading