From b0c9a4d74eceb8d1d4d00ddc4e50b5f4a4961485 Mon Sep 17 00:00:00 2001 From: aj Date: Wed, 13 May 2026 14:27:55 +0800 Subject: [PATCH] fix(auth): support comma-separated --scope in auth login (#764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lark-cli auth login --scope "a,b"` previously sent the raw comma-joined string to the device authorization endpoint, which treats it as a single malformed scope and fails with: device authorization failed: The provided scope list contains invalid or malformed scopes. OAuth 2.0 (RFC 6749 §3.3) requires space-delimited scopes on the wire, but commas are the more natural separator for users typing on a shell (quoting whitespace is awkward, especially for AI-agent generated commands). Accept both: split on commas/whitespace, trim, dedupe, then re-join with single spaces. Also adds unit tests covering single, comma, space, mixed, dedupe, and trailing-separator inputs. Co-authored-by: aj <2072584+meijing0114@users.noreply.github.com> --- cmd/auth/login.go | 42 ++++++++++++++++++++++++++++++++++++++++-- cmd/auth/login_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 365b495b..916fda9e 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -62,7 +62,7 @@ browser. Run it in the background and retrieve the verification URL from its out } cmdutil.SetSupportedIdentities(cmd, []string{"user"}) - cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)") + cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated)") cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes") available := sortedKnownDomains() cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil, @@ -185,7 +185,11 @@ func authLoginRun(opts *LoginOptions) error { } } - finalScope := opts.Scope + // Normalize --scope so users can pass either OAuth-standard space-separated + // values or the more natural comma-separated list. RFC 6749 §3.3 mandates + // space-delimited scopes in the wire request, so the device authorization + // endpoint rejects raw "a,b" strings as a single malformed scope. + finalScope := normalizeScopeInput(opts.Scope) // Resolve scopes from domain/permission filters if len(selectedDomains) > 0 || opts.Recommend { @@ -532,6 +536,40 @@ func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool { return false } +// normalizeScopeInput accepts a user-supplied --scope value that may use +// commas, spaces, tabs, or newlines (or any mix) as separators and returns the +// canonical OAuth 2.0 wire form: a single space-joined string with empties +// trimmed and duplicates removed (first occurrence wins; order preserved). +// +// Examples: +// +// "vc:note:read,vc:meeting.meetingevent:read" -> "vc:note:read vc:meeting.meetingevent:read" +// "a, b , c" -> "a b c" +// "a b a" -> "a b" +// "" -> "" +func normalizeScopeInput(raw string) string { + if raw == "" { + return "" + } + // Treat both commas and any whitespace as separators. + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' + }) + if len(fields) == 0 { + return "" + } + seen := make(map[string]struct{}, len(fields)) + out := make([]string, 0, len(fields)) + for _, f := range fields { + if _, ok := seen[f]; ok { + continue + } + seen[f] = struct{}{} + out = append(out, f) + } + return strings.Join(out, " ") +} + // suggestDomain finds the best "did you mean" match for an unknown domain. func suggestDomain(input string, known map[string]bool) string { // Check common cases: prefix match or input is a substring diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index ea950346..8687d313 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -70,6 +70,32 @@ func TestSuggestDomain_ExactMatch(t *testing.T) { } } +func TestNormalizeScopeInput(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"single", "vc:note:read", "vc:note:read"}, + {"comma", "vc:note:read,vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"}, + {"space", "vc:note:read vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"}, + {"comma_and_spaces", "vc:note:read, vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"}, + {"mixed_separators", "a, b\tc\nd e", "a b c d e"}, + {"trim_and_dedup", " a , b , a ", "a b"}, + {"trailing_separators", "a,b,,", "a b"}, + {"only_separators", " , , ", ""}, + {"tab_separated", "im:message:send\toffline_access", "im:message:send offline_access"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := normalizeScopeInput(tc.in); got != tc.want { + t.Errorf("normalizeScopeInput(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) { // Empty AuthTypes defaults to ["user"] sc := common.Shortcut{AuthTypes: nil}