From b5ae51a22c52d1751e35ace058c7c8ef4e06e41a Mon Sep 17 00:00:00 2001 From: cookier <5186526+cookier@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:18:04 +0800 Subject: [PATCH 1/2] fix(auth): explain invalid scopes in auth login --- cmd/auth/login.go | 3 + cmd/auth/login_scope_validation.go | 141 ++++++++++++++++++++++++ cmd/auth/login_scope_validation_test.go | 88 +++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 cmd/auth/login_scope_validation.go create mode 100644 cmd/auth/login_scope_validation_test.go diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 4b91dddf..1472716e 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -221,6 +221,9 @@ func authLoginRun(opts *LoginOptions) error { } authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut) if err != nil { + if scopeErr := explainScopeRequestError(opts.Ctx, f, config, finalScope, err); scopeErr != nil { + return scopeErr + } return output.ErrAuth("device authorization failed: %v", err) } diff --git a/cmd/auth/login_scope_validation.go b/cmd/auth/login_scope_validation.go new file mode 100644 index 00000000..8622790c --- /dev/null +++ b/cmd/auth/login_scope_validation.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/shortcuts" +) + +type requestedScopeDiagnostics struct { + Unknown []string + NotEnabled []string + Suggestions map[string][]string +} + +func explainScopeRequestError(ctx context.Context, f *cmdutil.Factory, config *core.CliConfig, requestedScope string, requestErr error) error { + if requestErr == nil { + return nil + } + if !strings.Contains(strings.ToLower(requestErr.Error()), "invalid or malformed scope") { + return nil + } + + var enabled map[string]bool + if info, err := getAppInfo(ctx, f, config.AppID); err == nil && info != nil { + enabled = make(map[string]bool, len(info.UserScopes)) + for _, scope := range info.UserScopes { + enabled[scope] = true + } + } + + diag := diagnoseRequestedScopes(requestedScope, knownUserScopes(), enabled) + if len(diag.Unknown) == 0 && len(diag.NotEnabled) == 0 { + return nil + } + + var lines []string + lines = append(lines, "requested scope list contains invalid entries:") + for _, scope := range diag.Unknown { + line := fmt.Sprintf("- unknown scope: %s", scope) + if suggestions := diag.Suggestions[scope]; len(suggestions) > 0 { + line += fmt.Sprintf(" (did you mean: %s?)", strings.Join(suggestions, ", ")) + } + lines = append(lines, line) + } + for _, scope := range diag.NotEnabled { + lines = append(lines, fmt.Sprintf("- scope not enabled for current app: %s", scope)) + } + lines = append(lines, `tip: run "lark-cli auth scopes" to inspect enabled app scopes, or prefer --domain/--recommend when possible`) + return output.ErrValidation("%s", strings.Join(lines, "\n")) +} + +func diagnoseRequestedScopes(scopeArg string, known map[string]bool, enabled map[string]bool) requestedScopeDiagnostics { + diag := requestedScopeDiagnostics{ + Suggestions: make(map[string][]string), + } + seenUnknown := make(map[string]bool) + seenDisabled := make(map[string]bool) + + for _, scope := range strings.Fields(scopeArg) { + if scope == "" || scope == "offline_access" { + continue + } + if !known[scope] { + if !seenUnknown[scope] { + diag.Unknown = append(diag.Unknown, scope) + diag.Suggestions[scope] = suggestScopes(scope, known) + seenUnknown[scope] = true + } + continue + } + if enabled != nil && !enabled[scope] && !seenDisabled[scope] { + diag.NotEnabled = append(diag.NotEnabled, scope) + seenDisabled[scope] = true + } + } + + sort.Strings(diag.Unknown) + sort.Strings(diag.NotEnabled) + return diag +} + +func knownUserScopes() map[string]bool { + scopes := make(map[string]bool) + for _, scope := range registry.CollectAllScopesFromMeta("user") { + scopes[scope] = true + } + for _, sc := range shortcuts.AllShortcuts() { + if !shortcutSupportsIdentity(sc, "user") { + continue + } + for _, scope := range sc.ScopesForIdentity("user") { + scopes[scope] = true + } + } + scopes["offline_access"] = true + return scopes +} + +func suggestScopes(input string, known map[string]bool) []string { + candidates := make(map[string]bool) + parts := strings.Split(input, ":") + + if len(parts) >= 2 { + prefix := parts[0] + ":" + parts[1] + ":" + for scope := range known { + if strings.HasPrefix(scope, prefix) { + candidates[scope] = true + } + } + } + if len(candidates) == 0 && len(parts) >= 1 { + prefix := parts[0] + ":" + for scope := range known { + if strings.HasPrefix(scope, prefix) { + candidates[scope] = true + } + } + } + + result := make([]string, 0, len(candidates)) + for scope := range candidates { + if scope != input { + result = append(result, scope) + } + } + sort.Strings(result) + if len(result) > 3 { + result = result[:3] + } + return result +} diff --git a/cmd/auth/login_scope_validation_test.go b/cmd/auth/login_scope_validation_test.go new file mode 100644 index 00000000..c90eac27 --- /dev/null +++ b/cmd/auth/login_scope_validation_test.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +func TestDiagnoseRequestedScopes_UnknownAndDisabled(t *testing.T) { + known := map[string]bool{ + "base:app:create": true, + "base:field:create": true, + "base:view:write_only": true, + } + enabled := map[string]bool{ + "base:field:create": true, + } + + diag := diagnoseRequestedScopes( + "base:app:create base:view:create base:view:write base:field:create offline_access", + known, + enabled, + ) + + if len(diag.NotEnabled) != 1 || diag.NotEnabled[0] != "base:app:create" { + t.Fatalf("unexpected disabled scopes: %#v", diag.NotEnabled) + } + if len(diag.Unknown) != 2 || diag.Unknown[0] != "base:view:create" || diag.Unknown[1] != "base:view:write" { + t.Fatalf("unexpected unknown scopes: %#v", diag.Unknown) + } + if got := diag.Suggestions["base:view:create"]; len(got) == 0 || got[0] != "base:view:write_only" { + t.Fatalf("expected suggestion for base:view:create, got %#v", got) + } + if got := diag.Suggestions["base:view:write"]; len(got) == 0 || got[0] != "base:view:write_only" { + t.Fatalf("expected suggestion for base:view:write, got %#v", got) + } +} + +func TestExplainScopeRequestError_FormatsDetailedValidation(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + + err := explainScopeRequestError( + context.Background(), + f, + &core.CliConfig{AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu}, + "base:view:create base:view:write", + errors.New("Device authorization failed: The provided scope list contains invalid or malformed scopes. Please ensure all scopes are valid."), + ) + if err == nil { + t.Fatal("expected validation error") + } + msg := err.Error() + if !strings.Contains(msg, "unknown scope: base:view:create") { + t.Fatalf("expected unknown scope detail, got: %s", msg) + } + if !strings.Contains(msg, "base:view:write_only") { + t.Fatalf("expected suggestion in message, got: %s", msg) + } + if !strings.Contains(msg, "lark-cli auth scopes") { + t.Fatalf("expected auth scopes hint, got: %s", msg) + } +} + +func TestExplainScopeRequestError_IgnoresOtherErrors(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + + err := explainScopeRequestError( + context.Background(), + f, + &core.CliConfig{AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu}, + "base:view:create", + errors.New("Device authorization failed: temporary network timeout"), + ) + if err != nil { + t.Fatalf("expected nil for unrelated errors, got %v", err) + } +} From ea800fe9c5d36137bd7056ba38597b2eb3b764c5 Mon Sep 17 00:00:00 2001 From: cookier <5186526+cookier@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:45:44 +0800 Subject: [PATCH 2/2] fix(auth): tighten invalid-scope diagnostics --- cmd/auth/login_scope_validation.go | 32 ++++++++++-- cmd/auth/login_scope_validation_test.go | 65 +++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/cmd/auth/login_scope_validation.go b/cmd/auth/login_scope_validation.go index 8622790c..c9e19f4a 100644 --- a/cmd/auth/login_scope_validation.go +++ b/cmd/auth/login_scope_validation.go @@ -22,24 +22,36 @@ type requestedScopeDiagnostics struct { Suggestions map[string][]string } +var loadAppInfo = getAppInfo + func explainScopeRequestError(ctx context.Context, f *cmdutil.Factory, config *core.CliConfig, requestedScope string, requestErr error) error { if requestErr == nil { return nil } - if !strings.Contains(strings.ToLower(requestErr.Error()), "invalid or malformed scope") { + if !isInvalidScopeError(requestErr) { return nil } var enabled map[string]bool - if info, err := getAppInfo(ctx, f, config.AppID); err == nil && info != nil { + var appInfoErr error + if info, err := loadAppInfo(ctx, f, config.AppID); err == nil && info != nil { enabled = make(map[string]bool, len(info.UserScopes)) for _, scope := range info.UserScopes { enabled[scope] = true } + } else { + appInfoErr = err } diag := diagnoseRequestedScopes(requestedScope, knownUserScopes(), enabled) if len(diag.Unknown) == 0 && len(diag.NotEnabled) == 0 { + if appInfoErr != nil { + return output.ErrAuth( + "requested scope list could not be fully diagnosed: failed to inspect enabled app scopes automatically: %v\nrequested scopes: %s\nhint: run \"lark-cli auth scopes\" to inspect enabled app scopes, or prefer --domain/--recommend when possible", + appInfoErr, + strings.Join(uniqueScopeList(requestedScope), " "), + ) + } return nil } @@ -55,8 +67,22 @@ func explainScopeRequestError(ctx context.Context, f *cmdutil.Factory, config *c for _, scope := range diag.NotEnabled { lines = append(lines, fmt.Sprintf("- scope not enabled for current app: %s", scope)) } + if appInfoErr != nil { + lines = append(lines, fmt.Sprintf("- enabled app scopes could not be fully inspected automatically: %v", appInfoErr)) + } lines = append(lines, `tip: run "lark-cli auth scopes" to inspect enabled app scopes, or prefer --domain/--recommend when possible`) - return output.ErrValidation("%s", strings.Join(lines, "\n")) + return output.ErrAuth("%s", strings.Join(lines, "\n")) +} + +func isInvalidScopeError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "invalid or malformed scope") { + return true + } + return strings.Contains(msg, "scope") && (strings.Contains(msg, "invalid") || strings.Contains(msg, "malformed")) } func diagnoseRequestedScopes(scopeArg string, known map[string]bool, enabled map[string]bool) requestedScopeDiagnostics { diff --git a/cmd/auth/login_scope_validation_test.go b/cmd/auth/login_scope_validation_test.go index c90eac27..c99b84b4 100644 --- a/cmd/auth/login_scope_validation_test.go +++ b/cmd/auth/login_scope_validation_test.go @@ -11,6 +11,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" ) func TestDiagnoseRequestedScopes_UnknownAndDisabled(t *testing.T) { @@ -58,6 +59,10 @@ func TestExplainScopeRequestError_FormatsDetailedValidation(t *testing.T) { if err == nil { t.Fatal("expected validation error") } + exitErr, ok := err.(*output.ExitError) + if !ok || exitErr.Code != output.ExitAuth { + t.Fatalf("expected auth exit error, got %#v", err) + } msg := err.Error() if !strings.Contains(msg, "unknown scope: base:view:create") { t.Fatalf("expected unknown scope detail, got: %s", msg) @@ -86,3 +91,63 @@ func TestExplainScopeRequestError_IgnoresOtherErrors(t *testing.T) { t.Fatalf("expected nil for unrelated errors, got %v", err) } } + +func TestExplainScopeRequestError_NotEnabledScope(t *testing.T) { + orig := loadAppInfo + loadAppInfo = func(_ context.Context, _ *cmdutil.Factory, _ string) (*appInfo, error) { + return &appInfo{UserScopes: []string{"base:field:create"}}, nil + } + defer func() { loadAppInfo = orig }() + + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + + err := explainScopeRequestError( + context.Background(), + f, + &core.CliConfig{AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu}, + "base:app:create", + errors.New("Device authorization failed: invalid scope"), + ) + if err == nil { + t.Fatal("expected auth error") + } + msg := err.Error() + if !strings.Contains(msg, "scope not enabled for current app: base:app:create") { + t.Fatalf("expected not-enabled detail, got: %s", msg) + } + if !strings.Contains(msg, "lark-cli auth scopes") { + t.Fatalf("expected auth scopes hint, got: %s", msg) + } +} + +func TestExplainScopeRequestError_AppScopeLookupFailureStillGuidesUser(t *testing.T) { + orig := loadAppInfo + loadAppInfo = func(_ context.Context, _ *cmdutil.Factory, _ string) (*appInfo, error) { + return nil, errors.New("lookup failed") + } + defer func() { loadAppInfo = orig }() + + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + + err := explainScopeRequestError( + context.Background(), + f, + &core.CliConfig{AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu}, + "base:app:create", + errors.New("Device authorization failed: malformed scope list"), + ) + if err == nil { + t.Fatal("expected auth error") + } + msg := err.Error() + if !strings.Contains(msg, "could not be fully diagnosed") { + t.Fatalf("expected fallback guidance, got: %s", msg) + } + if !strings.Contains(msg, "lookup failed") { + t.Fatalf("expected app scope lookup error in message, got: %s", msg) + } +}