mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
fdc1ba4df7
* feat: use remote scopes.json for login scope recommendations Switch auth login's recommended-permission source from the compiled-in local table to a scopes.json fetched from the platform at login time. - add internal/auth/remote_scopes.go: brand-addressed GET with a ~1s timeout and whole-file (binary) validation; scopes are used verbatim when the file is valid, and any fetch/parse/format failure falls back silently to the existing local computation - login.go: fetch remote scopes once per login and use them for domain validation, all-expansion, and per-domain scope selection; drop the terminal interactive page and the local auto-approve filter chain, so --recommend is now equivalent to --domain all - remove login_interactive.go and the service-description getters left orphaned by the interactive-page removal - keep the three entry flags (bare login / --recommend / --domain all) as an equivalent transitional surface * refactor: remove orphaned auto-approve loader and harden scope check * fix: accept variable segment counts in remote scope validation * feat(auth): add support for status message from device flow auth * fix(auth): remove message field from login warning payload * fix(auth/login): improve login result heading and warning hint logic * fix: resolve auth scopes locally for custom builds and exclusions Remote-first login (reading the published scopes.json) can drop scopes that a specific build or the local resolution still legitimately covers. Two cases are now handled locally: - A build that injected business commands via WithCommandSets has a scope universe the standard-CLI scopes.json does not cover. Detect it by comparing registered command paths against the built-in set and skip the remote fetch, so such a build resolves locally instead of silently losing its custom scopes. - Fold the local domain resolution into the --exclude validation universe. Once the server drops a batch-withheld scope (im:message.send_as_user) from the published list, the remote candidate set no longer carries it, but --domain im --exclude im:message.send_as_user must stay a valid no-op; a domain that never had the scope still rejects it as unknown. Also raise the remote fetch timeout from 1s to 2s, and make the auth tests hermetic by defaulting the remote fetch to unavailable in TestMain (a test that needs a specific remote overrides the seam). ---------
125 lines
4.0 KiB
Go
125 lines
4.0 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/transport"
|
|
)
|
|
|
|
const (
|
|
remoteScopesPath = "/lark-cli/apis/scopes.json"
|
|
remoteScopesTimeout = 2 * time.Second
|
|
maxRemoteScopesSize = 10 * 1024 * 1024 // 10MB, aligned with internal/registry/remote.go
|
|
)
|
|
|
|
// remoteScopesURLForTest is the injection seam for unit tests to point at an
|
|
// httptest server. It is empty at runtime, where the brand-hardcoded production
|
|
// URL is used, and must never carry an internal / non-production domain.
|
|
var remoteScopesURLForTest string
|
|
|
|
type remoteScopesFile struct {
|
|
Scopes map[string]remoteDomainScopes `json:"scopes"`
|
|
}
|
|
|
|
type remoteDomainScopes struct {
|
|
UserScopes []string `json:"user_scopes"`
|
|
TenantScopes []string `json:"tenant_scopes"`
|
|
}
|
|
|
|
func remoteScopesURL(brand core.LarkBrand) string {
|
|
if remoteScopesURLForTest != "" {
|
|
return remoteScopesURLForTest
|
|
}
|
|
return core.ResolveOpenBaseURL(brand) + remoteScopesPath
|
|
}
|
|
|
|
// FetchRemoteScopes fetches and binary-validates the remote scopes.json.
|
|
// It returns (domain -> user_scopes, true) when the whole file is usable, or
|
|
// (nil, false) when the caller should fall back to the local set. Any failure
|
|
// (network / timeout / non-2xx / empty / bad JSON / structure mismatch /
|
|
// malformed scope) returns (nil, false) silently — no warning, no telemetry.
|
|
func FetchRemoteScopes(brand core.LarkBrand) (map[string][]string, bool) {
|
|
client := transport.NewHTTPClient(remoteScopesTimeout)
|
|
req, err := http.NewRequest(http.MethodGet, remoteScopesURL(brand), nil)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, false
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxRemoteScopesSize))
|
|
if err != nil || len(body) == 0 {
|
|
return nil, false
|
|
}
|
|
var file remoteScopesFile
|
|
if err := json.Unmarshal(body, &file); err != nil {
|
|
return nil, false
|
|
}
|
|
return validateRemoteScopes(file)
|
|
}
|
|
|
|
func validateRemoteScopes(file remoteScopesFile) (map[string][]string, bool) {
|
|
if len(file.Scopes) == 0 {
|
|
return nil, false
|
|
}
|
|
result := make(map[string][]string, len(file.Scopes))
|
|
for domain, ds := range file.Scopes {
|
|
if ds.UserScopes == nil { // missing user_scopes field / null → whole file untrusted
|
|
return nil, false
|
|
}
|
|
for _, s := range ds.UserScopes {
|
|
if !isValidScopeFormat(s) {
|
|
return nil, false
|
|
}
|
|
}
|
|
result[domain] = ds.UserScopes
|
|
}
|
|
return result, true
|
|
}
|
|
|
|
// isValidScopeFormat does a light corruption check on a scope string. Lark
|
|
// scopes are ":"-separated but the segment count is NOT fixed: real scopes
|
|
// range from two segments ("im:message", "mail:event") through three
|
|
// ("base:app:copy") to four ("board:whiteboard:node:read",
|
|
// "drive:file:view_record:readonly"), and a "." may appear in any segment
|
|
// ("im:message.send_as_user", "vc:meeting.meetingevent:read"). We therefore
|
|
// only require at least two segments, each non-empty and built from the
|
|
// characters Lark uses (lowercase letters, digits, "_", "."). This still
|
|
// rejects empty segments and whitespace — a whitespace character would corrupt
|
|
// the space-joined OAuth scope string on the wire — as well as other garbage;
|
|
// under the binary model any such scope discards the whole remote file. The
|
|
// real defence against a tampered file is the trusted HTTPS source plus the
|
|
// IAM authorization page, not this shape check, so it is deliberately lenient
|
|
// on segment count to avoid falsely rejecting a valid published file.
|
|
// i18n / tenant / version are not checked.
|
|
func isValidScopeFormat(s string) bool {
|
|
parts := strings.Split(s, ":")
|
|
if len(parts) < 2 {
|
|
return false
|
|
}
|
|
for _, p := range parts {
|
|
if p == "" {
|
|
return false
|
|
}
|
|
for _, c := range p {
|
|
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.') {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|