diff --git a/cmd/auth/login.go b/cmd/auth/login.go index add8762bbf..93c30c0cd1 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -348,7 +348,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo } log(msg.WaitingAuth) result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand, - opts.DeviceCode, 5, 180, f.IOStreams.ErrOut) + opts.DeviceCode, 5, 600, f.IOStreams.ErrOut) if !result.OK { if shouldRemoveLoginRequestedScope(result) { diff --git a/cmd/auth/login_result.go b/cmd/auth/login_result.go index abe63e3543..d52a0e0bd2 100644 --- a/cmd/auth/login_result.go +++ b/cmd/auth/login_result.go @@ -169,7 +169,7 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory if loginSucceeded { b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue)) fmt.Fprintln(f.IOStreams.Out, string(b)) - return nil + return output.ErrBare(output.ExitAuth) } detail := map[string]interface{}{ "requested": issue.Summary.Requested, @@ -200,9 +200,6 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory if issue.Hint != "" { fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint) } - if loginSucceeded { - return nil - } return output.ErrBare(output.ExitAuth) } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index a3b84af374..82dc3cb307 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts/common" "github.com/zalando/go-keyring" @@ -371,8 +372,12 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) { Granted: []string{"base:app:copy"}, }, }, "ou_user", "tester") - if err != nil { - t.Fatalf("expected nil error, got %v", err) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %v", err) + } + if exitErr.Code != output.ExitAuth { + t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth) } got := stderr.String() for _, want := range []string{ @@ -410,8 +415,12 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) { Granted: []string{"base:app:copy"}, }, }, "ou_user", "tester") - if err != nil { - t.Fatalf("expected nil error, got %v", err) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %v", err) + } + if exitErr.Code != output.ExitAuth { + t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth) } var data map[string]interface{} @@ -616,8 +625,12 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) Ctx: context.Background(), Scope: "im:message:send", }) - if err != nil { - t.Fatalf("expected nil error, got %v", err) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %v", err) + } + if exitErr.Code != output.ExitAuth { + t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth) } got := stderr.String() for _, want := range []string{ diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 965c6eeb52..fb611d1218 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -142,8 +142,12 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec errOut = io.Discard } + if interval < 1 { + interval = 5 + } + const maxPollInterval = 60 - const maxPollAttempts = 200 + const maxPollAttempts = 600 endpoints := ResolveOAuthEndpoints(brand) deadline := time.Now().Add(time.Duration(expiresIn) * time.Second) diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go index 5493220aff..8b3f4d0c95 100644 --- a/internal/auth/device_flow_test.go +++ b/internal/auth/device_flow_test.go @@ -5,10 +5,12 @@ package auth import ( "bytes" + "context" "fmt" "log" "net/http" "strings" + "sync/atomic" "testing" "time" @@ -17,6 +19,12 @@ import ( "github.com/larksuite/cli/internal/keychain" ) +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + // TestResolveOAuthEndpoints_Feishu validates endpoints for the Feishu brand. func TestResolveOAuthEndpoints_Feishu(t *testing.T) { ep := ResolveOAuthEndpoints(core.BrandFeishu) @@ -172,3 +180,33 @@ func TestLogAuthError_RecordsStructuredEntry(t *testing.T) { t.Fatalf("expected truncated cmdline in log, got %q", got) } } + +func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) { + t.Parallel() + + var requests atomic.Int32 + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requests.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: http.NoBody, + }, nil + }), + } + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + t.Cleanup(cancel) + + result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil) + if result == nil { + t.Fatal("PollDeviceToken() returned nil result") + } + if result.Message != "Polling was cancelled" { + t.Fatalf("PollDeviceToken() message = %q, want polling cancellation", result.Message) + } + if got := requests.Load(); got != 0 { + t.Fatalf("PollDeviceToken() sent %d requests before context cancellation, want 0", got) + } +} diff --git a/internal/output/exitcode.go b/internal/output/exitcode.go index ae65caa715..6fde811a82 100644 --- a/internal/output/exitcode.go +++ b/internal/output/exitcode.go @@ -10,7 +10,7 @@ const ( ExitOK = 0 // 成功 ExitAPI = 1 // API / 通用错误(含 permission、not_found、conflict、rate_limit) ExitValidation = 2 // 参数校验失败 - ExitAuth = 3 // 认证失败(token 无效 / 过期) + ExitAuth = 3 // 认证失败(token 无效 / 过期),或登录成功但请求 scopes 未全部授予 ExitNetwork = 4 // 网络错误(连接超时、DNS 解析失败等) ExitInternal = 5 // 内部错误(不应发生) ExitContentSafety = 6 // content safety violation (block mode)