-
Notifications
You must be signed in to change notification settings - Fork 466
fix(auth): explain invalid scopes in auth login #427
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
cookier
wants to merge
2
commits into
larksuite:main
Choose a base branch
from
cookier:fix/auth-login-invalid-scope-diagnostics
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.
+323
−0
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
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
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,167 @@ | ||
| // 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 | ||
| } | ||
|
|
||
| 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 !isInvalidScopeError(requestErr) { | ||
| return nil | ||
| } | ||
|
|
||
| var enabled map[string]bool | ||
| 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 | ||
| } | ||
|
|
||
| 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)) | ||
| } | ||
| 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.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 { | ||
| 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 | ||
| } | ||
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,153 @@ | ||
| // 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" | ||
| "github.com/larksuite/cli/internal/output" | ||
| ) | ||
|
|
||
| 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") | ||
| } | ||
| 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) | ||
| } | ||
| 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) | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
| } | ||
| } |
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.
Uh oh!
There was an error while loading. Please reload this page.