diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 365b495ba..916fda9ea 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 ea9503464..8687d313a 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}