-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): rewrite auth and session commands as native Go #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
miguelsanchez-upsun
wants to merge
57
commits into
main
Choose a base branch
from
feature/cli-76-auth-rewrite
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 9815224
fix(session): simplify FileStore.Delete, document MemStore concurrency
miguelsanchez-upsun a2df8c6
feat(session): add session ID resolver
miguelsanchez-upsun 56b1a9d
fix(session): collapse consecutive invalid chars in sanitiseID to mat…
miguelsanchez-upsun 16d71c4
feat(session): add Manager
miguelsanchez-upsun ae795db
fix(session): add MkdirAll to Store interface, fix DeleteAll error sw…
miguelsanchez-upsun 4b15bbb
feat(auth): add PKCE helpers (RFC 7636)
miguelsanchez-upsun f177685
feat(auth): add sessionTokenSource backed by session.Manager
miguelsanchez-upsun 4ba6b3b
fix(auth): wrap load error in unsafeRefreshToken
miguelsanchez-upsun 3ece677
feat(auth): replace NewLegacyCLIClient with NewClient backed by sessi…
miguelsanchez-upsun 22da051
fix(init): rename legacyCLIClient to authClient
miguelsanchez-upsun 9142946
test(mockapi): add phone verification endpoints
miguelsanchez-upsun 7352117
test(mockapi): add PKCE authorize, refresh_token, revoke endpoints
miguelsanchez-upsun a7c0e7f
fix(mockapi): remove /v1/ prefix from phone verification routes
miguelsanchez-upsun 068dbc5
test(integration): extend auth:info baseline tests
miguelsanchez-upsun 70e729e
test(integration): fix dead code and improve NoAutoLogin test debugga…
miguelsanchez-upsun 05e321a
test(integration): add auth:logout baseline tests
miguelsanchez-upsun 1ee36b4
test(integration): add auth:token baseline tests
miguelsanchez-upsun 8fe33cc
test(integration): add auth:api-token-login baseline tests
miguelsanchez-upsun 981fa5f
test(integration): add auth:browser-login baseline tests
miguelsanchez-upsun 5edfda3
test(integration): add auth:verify-phone-number baseline tests
miguelsanchez-upsun 140b846
test(integration): add session:switch baseline tests
miguelsanchez-upsun a157ad8
fix(mockapi): remove incorrect phone-verification routes; use actual …
miguelsanchez-upsun ddee460
feat(auth): implement auth:info command in Go
miguelsanchez-upsun 12250fe
feat(auth): implement auth:token command in Go
miguelsanchez-upsun 9d24a05
fix(auth): resolve API base URL and API token from env vars in newAPI…
miguelsanchez-upsun 1319fa6
feat(auth): implement auth:logout command in Go
miguelsanchez-upsun cbddcab
feat(auth): implement auth:api-token-login command in Go
miguelsanchez-upsun 735d0fc
feat(auth): implement auth:browser-login with native PKCE flow
miguelsanchez-upsun 1f9a26f
feat(auth): implement auth:verify-phone-number command in Go
miguelsanchez-upsun 6fe0f54
feat(session): implement session:switch command in Go
miguelsanchez-upsun 6cf8378
feat(ux): match PHP-style help output for Go auth commands
miguelsanchez-upsun aabc326
chore: promote spf13/pflag to direct dependency
miguelsanchez-upsun 426eb03
fix(auth): use correct env var names matching PHP CLI (AUTH_URL, API_…
miguelsanchez-upsun 917cc71
fix(auth): inject token into PHP delegation; fix SSH finalization and…
miguelsanchez-upsun eba466a
test(auth): add regression test for PHP command auth after Go login
miguelsanchez-upsun 7a32f67
feat(auth): align Go commands with PHP logic parity
miguelsanchez-upsun 7d409d5
refactor(auth): consolidate OAuth2 URL resolution into internal/auth/…
miguelsanchez-upsun 8c36c0b
refactor(auth): consolidate api token exchange into single exchangeAP…
miguelsanchez-upsun 8f2fca7
fix(auth): thread context into token refresh; drop unused refreshToke…
miguelsanchez-upsun 1edbc4b
fix(auth): replace http.DefaultClient with injectable http client fields
miguelsanchez-upsun ac9e5ca
fix(auth): remove misleading error return from delegateSSHFinalization
miguelsanchez-upsun bd36870
fix(auth): check token expiry in browser-login already-logged-in guard
miguelsanchez-upsun 31a9b08
fix(session): warn instead of silently swallow ResolveSessionID error…
miguelsanchez-upsun 78360e9
fix(auth): use unicode.IsDigit for phone verification code validation
miguelsanchez-upsun 2e94893
refactor(auth): simplify and deduplicate auth/session helpers
miguelsanchez-upsun 533b759
test(auth): add integration tests for decline-relogin and logout --other
miguelsanchez-upsun 26b287c
test(auth): add unit tests for URL resolution, session sanitisation, …
miguelsanchez-upsun 2c4a613
fix(auth): align verify-phone method labels and logout message with P…
miguelsanchez-upsun 02c5b78
feat(auth): retry api-token-login up to 5 times on invalid token (PHP…
miguelsanchez-upsun 4003348
fix(auth): PHP parity for decline-relogin exit code, logout wording, …
miguelsanchez-upsun 9486773
fix(auth): cap verify-phone retries at 5 attempts to match PHP setMax…
miguelsanchez-upsun ae7ddb3
fix(auth): restore POST body on 401 retry using GetBody callback
miguelsanchez-upsun 62fafcc
feat(auth): add re-login prompt to auth:info on expired/missing session
miguelsanchez-upsun 191b06c
feat(auth): auto-accept browser login on auth:info when --yes is passed
miguelsanchez-upsun e335db6
chore(lint): fix all golangci-lint issues
miguelsanchez-upsun effa719
fix(auth): address PR review comments on Store abstraction and transport
miguelsanchez-upsun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.") | ||
|
|
||
| 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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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).