Files
dc-bytedance fdc1ba4df7 feat: use remote scopes.json for login scope recommendations (#1799)
* 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).

---------
2026-09-07 20:19:53 +08:00

277 lines
9.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"bytes"
"context"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/keychain"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
// TestResolveOAuthEndpoints_Feishu validates endpoints for the Feishu brand.
func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
ep := ResolveOAuthEndpoints(core.BrandFeishu)
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
}
if ep.Revoke != "https://accounts.feishu.cn/oauth/v1/revoke" {
t.Errorf("Revoke = %q", ep.Revoke)
}
if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
t.Errorf("Token = %q", ep.Token)
}
}
// TestResolveOAuthEndpoints_Lark validates endpoints for the Lark brand.
func TestResolveOAuthEndpoints_Lark(t *testing.T) {
ep := ResolveOAuthEndpoints(core.BrandLark)
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
}
if ep.Revoke != "https://accounts.larksuite.com/oauth/v1/revoke" {
t.Errorf("Revoke = %q", ep.Revoke)
}
if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
t.Errorf("Token = %q", ep.Token)
}
}
// TestRequestDeviceAuthorization_LogsResponse checks if API responses are logged correctly.
func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
Headers: http.Header{
"Content-Type": []string{"application/json"},
"X-Tt-Logid": []string{"device-log-id"},
},
})
var buf bytes.Buffer
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
}, func() []string {
return []string{"lark-cli", "auth", "login", "--device-code", "device-code-secret", "--app-secret=top-secret"}
})
t.Cleanup(restore)
_, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil)
if err != nil {
t.Fatalf("RequestDeviceAuthorization() error: %v", err)
}
got := buf.String()
if !strings.Contains(got, "time=2026-04-02T03:04:05Z") {
t.Fatalf("expected time in log, got %q", got)
}
if !strings.Contains(got, "path=missing") {
t.Fatalf("expected path in log, got %q", got)
}
if !strings.Contains(got, "status=200") {
t.Fatalf("expected status=200 in log, got %q", got)
}
if !strings.Contains(got, "x-tt-logid=device-log-id") {
t.Fatalf("expected x-tt-logid in log, got %q", got)
}
if !strings.Contains(got, "cmdline=lark-cli auth login ...") {
t.Fatalf("expected cmdline in log, got %q", got)
}
}
// TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated.
func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) {
got := keychain.FormatAuthCmdline([]string{
"lark-cli",
"auth",
"login",
"--device-code", "device-code-secret",
"--app-secret=top-secret",
"--scope", "contact:read",
})
want := "lark-cli auth login ..."
if got != want {
t.Fatalf("formatAuthCmdline() = %q, want %q", got, want)
}
}
// TestLogAuthResponse_IgnoresTypedNilHTTPResponse tests that a typed nil HTTP response is ignored gracefully.
func TestLogAuthResponse_IgnoresTypedNilHTTPResponse(t *testing.T) {
var buf bytes.Buffer
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), nil, nil)
t.Cleanup(restore)
var resp *http.Response
logHTTPResponse(resp)
if got := buf.String(); got != "" {
t.Fatalf("expected no log output, got %q", got)
}
}
// TestLogAuthResponse_HandlesNilSDKResponse verifies that a nil SDK response is handled without panicking.
func TestLogAuthResponse_HandlesNilSDKResponse(t *testing.T) {
var buf bytes.Buffer
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
}, func() []string {
return []string{"lark-cli", "auth", "status", "--verify"}
})
t.Cleanup(restore)
logSDKResponse(PathUserInfoV1, nil)
got := buf.String()
if !strings.Contains(got, "path="+PathUserInfoV1) {
t.Fatalf("expected sdk path in log, got %q", got)
}
if !strings.Contains(got, "status=0") {
t.Fatalf("expected zero status in log, got %q", got)
}
}
func TestLogAuthError_RecordsStructuredEntry(t *testing.T) {
var buf bytes.Buffer
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
}, func() []string {
return []string{"lark-cli", "auth", "login", "--device-code", "secret"}
})
t.Cleanup(restore)
keychain.LogAuthError("keychain", "Set", fmt.Errorf("keychain Set error: %w", http.ErrUseLastResponse))
got := buf.String()
if !strings.Contains(got, "auth-error") {
t.Fatalf("expected auth-error log entry, got %q", got)
}
if !strings.Contains(got, "component=keychain") {
t.Fatalf("expected component in log, got %q", got)
}
if !strings.Contains(got, "op=Set") {
t.Fatalf("expected op in log, got %q", got)
}
if !strings.Contains(got, "error=\"keychain Set error: net/http: use last response\"") {
t.Fatalf("expected quoted error in log, got %q", got)
}
if !strings.Contains(got, "cmdline=lark-cli auth login ...") {
t.Fatalf("expected truncated cmdline in log, got %q", got)
}
}
func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
t.Parallel()
var requests atomic.Int32
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests.Add(1)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: http.NoBody,
}, nil
}),
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
t.Cleanup(cancel)
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
if result == nil {
t.Fatal("PollDeviceToken() returned nil result")
}
if result.Message != "Polling was cancelled" {
t.Fatalf("PollDeviceToken() message = %q, want polling cancellation", result.Message)
}
if got := requests.Load(); got != 0 {
t.Fatalf("PollDeviceToken() sent %d requests before context cancellation, want 0", got)
}
}
func TestPollDeviceToken_PreservesStatusMessage(t *testing.T) {
t.Parallel()
const statusMessage = "[不可申请,勿重试] 企业管理员禁止申请的权限:mail:user_mailbox.message:send\n" +
"[待审核,通过后用户需重新授权] 以下权限正在等待管理员审核:offline_access"
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{
"code": 0,
"access_token": "access-token",
"expires_in": 7200,
"scope": "approval:task:read",
"status_message": "[不可申请,勿重试] 企业管理员禁止申请的权限:mail:user_mailbox.message:send\n[待审核,通过后用户需重新授权] 以下权限正在等待管理员审核:offline_access"
}`)),
}, nil
}),
}
result := PollDeviceToken(context.Background(), client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 1, 3, nil)
if result == nil || !result.OK || result.Token == nil {
t.Fatalf("PollDeviceToken() = %#v, want successful token result", result)
}
if result.Token.StatusMessage != statusMessage {
t.Fatalf("StatusMessage = %q, want %q", result.Token.StatusMessage, statusMessage)
}
}
func TestPollDeviceToken_MissingStatusMessageIsEmpty(t *testing.T) {
t.Parallel()
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{
"code": 0,
"access_token": "access-token",
"expires_in": 7200,
"scope": "approval:task:read"
}`)),
}, nil
}),
}
result := PollDeviceToken(context.Background(), client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 1, 3, nil)
if result == nil || !result.OK || result.Token == nil {
t.Fatalf("PollDeviceToken() = %#v, want successful token result", result)
}
if result.Token.StatusMessage != "" {
t.Fatalf("StatusMessage = %q, want empty string", result.Token.StatusMessage)
}
}