Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
167 changes: 167 additions & 0 deletions cmd/auth/login_scope_validation.go
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
}
153 changes: 153 additions & 0 deletions cmd/auth/login_scope_validation_test.go
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)
}
}
Loading