mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
* fix(identitydiag): harden verify path and tighten status semantics Follow-ups to #957: - bound bot/user verify calls with a 10s timeout (mirrors the doctor endpoint probe) so a hanging server cannot wedge `auth status --verify` or `doctor` - return StatusNotConfigured (not StatusMissing) when the user-identity path is blocked by missing app config, matching the bot side - surface the `{code, msg}` envelope on bot-info HTTP 4xx responses so callers see why bot auth was rejected, not just the bare HTTP code - introduce identity{User,Bot,None} constants in cmd/auth/status.go and use the exported StatusMessage() in the human-readable note instead of raw status codes like "not_configured" - collapse the duplicated verify-failed identity construction in the user path into a local helper - cover the new failure paths with unit tests (HTTP 4xx with envelope, business error code, user server-rejected, expired user token, strict-mode user-only, missing app config for user) Change-Id: I581348a65f15b1452a6f48a3e3245d09257314ac * fix(identitydiag): decode bot/v3/info from "bot" field, not "data" `/open-apis/bot/v3/info` returns `{code, msg, bot: {...}}` — the bot payload is under `bot`, not `data` as the newer Lark API convention would suggest. The decoder was reading from a non-existent `data` field, so `envelope.Data.OpenID` was always empty and every successful verify was reported as `Bot identity: verify failed: open_id is empty`. The pre-existing test mocks used `{"data": {...}}` matching the buggy decoder, so unit tests passed while production reads of every Lark account failed verification. Fix: - change the JSON tag on the envelope from `json:"data"` to `json:"bot"` - update mocks in identitydiag and cmd/auth/status tests to emit `bot` Verified locally: `lark-cli doctor` now reports `bot_identity: pass` for both a normal account and a bot-only profile, restoring the behavior that #957 set out to deliver. Change-Id: Ib26dfdd5a0cc37d2d62537ae2bf5e854e67cb83c * fix(shortcuts/common): decode bot/v3/info from "bot" field, not "data" Same schema bug as the one fixed in identitydiag — `RuntimeContext. fetchBotInfo` reads from a non-existent "data" key, so every successful call would report "open_id is empty" once a caller starts depending on it. There are no production callers of `RuntimeContext.BotInfo()` yet (only tests + the `TestNewRuntimeContextWithBotInfo` helper), so this bug is dormant — but the pre-existing tests pass with the same wrong schema in their mocks, so the first real consumer would silently break. Fix: tag `json:"data"` → `json:"bot"` plus aligning the four mock fixtures in runner_botinfo_test.go. The Go field name `Data` is kept to minimize the diff; only the JSON contract is corrected. Change-Id: I11e1e871603e5349f8df29b1d58e35d07b628dfd
326 lines
9.3 KiB
Go
326 lines
9.3 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package identitydiag
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
larkauth "github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/credential"
|
|
)
|
|
|
|
const (
|
|
StatusReady = "ready"
|
|
StatusNotConfigured = "not_configured"
|
|
StatusMissing = "missing"
|
|
StatusNeedsRefresh = "needs_refresh"
|
|
StatusVerifyFailed = "verify_failed"
|
|
)
|
|
|
|
// verifyTimeout bounds each network call made during --verify so that a
|
|
// hanging server cannot wedge `auth status --verify` or `doctor`. Mirrors
|
|
// the 10s timeout used by the doctor endpoint probe.
|
|
const verifyTimeout = 10 * time.Second
|
|
|
|
// Result describes the independently usable bot and user identities.
|
|
type Result struct {
|
|
Bot Identity `json:"bot"`
|
|
User Identity `json:"user"`
|
|
}
|
|
|
|
// Identity is a single identity diagnostic result.
|
|
type Identity struct {
|
|
Status string `json:"status"`
|
|
Available bool `json:"available"`
|
|
Verified *bool `json:"verified,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
Hint string `json:"hint,omitempty"`
|
|
OpenID string `json:"openId,omitempty"`
|
|
AppName string `json:"appName,omitempty"`
|
|
UserName string `json:"userName,omitempty"`
|
|
TokenStatus string `json:"tokenStatus,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
ExpiresAt string `json:"expiresAt,omitempty"`
|
|
RefreshExpiresAt string `json:"refreshExpiresAt,omitempty"`
|
|
GrantedAt string `json:"grantedAt,omitempty"`
|
|
}
|
|
|
|
// Diagnose checks bot and user identities separately. When verify is false,
|
|
// it only reports local readiness and skips server calls.
|
|
func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Result {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
return Result{
|
|
Bot: diagnoseBot(ctx, f, cfg, verify),
|
|
User: diagnoseUser(ctx, f, cfg, verify),
|
|
}
|
|
}
|
|
|
|
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
|
|
if cfg == nil || cfg.AppID == "" {
|
|
return Identity{
|
|
Status: StatusNotConfigured,
|
|
Message: "Bot identity: not configured (missing app config)",
|
|
Hint: "run: lark-cli config --help",
|
|
}
|
|
}
|
|
if !cfg.CanBot() {
|
|
return Identity{
|
|
Status: StatusNotConfigured,
|
|
Message: "Bot identity: not configured (bot identity is not available in current credential context)",
|
|
Hint: "check strict mode or the active credential provider",
|
|
}
|
|
}
|
|
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) {
|
|
return Identity{
|
|
Status: StatusNotConfigured,
|
|
Message: "Bot identity: not configured (missing app secret or bot token)",
|
|
Hint: "run: lark-cli config --help",
|
|
}
|
|
}
|
|
|
|
id := Identity{
|
|
Status: StatusReady,
|
|
Available: true,
|
|
Message: "Bot identity: ready",
|
|
}
|
|
if !verify {
|
|
return id
|
|
}
|
|
|
|
token, err := resolveBotToken(ctx, f, cfg)
|
|
if err != nil {
|
|
status := StatusVerifyFailed
|
|
var unavailable *credential.TokenUnavailableError
|
|
if errors.As(err, &unavailable) {
|
|
status = StatusNotConfigured
|
|
}
|
|
return Identity{
|
|
Status: status,
|
|
Verified: boolPtr(false),
|
|
Message: "Bot identity: " + StatusMessage(status) + ": " + err.Error(),
|
|
Hint: "check app credentials or the active credential provider",
|
|
}
|
|
}
|
|
|
|
info, err := fetchBotInfo(ctx, f, cfg, token)
|
|
if err != nil {
|
|
return Identity{
|
|
Status: StatusVerifyFailed,
|
|
Verified: boolPtr(false),
|
|
Message: "Bot identity: verify failed: " + err.Error(),
|
|
Hint: "check app credentials, scopes, network, or tenant access token configuration",
|
|
}
|
|
}
|
|
|
|
id.Verified = boolPtr(true)
|
|
id.OpenID = info.OpenID
|
|
id.AppName = info.AppName
|
|
return id
|
|
}
|
|
|
|
func diagnoseUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
|
|
if cfg == nil || cfg.AppID == "" {
|
|
return Identity{
|
|
Status: StatusNotConfigured,
|
|
Message: "User identity: not configured (missing app config)",
|
|
Hint: "run: lark-cli config --help",
|
|
}
|
|
}
|
|
if cfg.UserOpenId == "" {
|
|
return Identity{
|
|
Status: StatusMissing,
|
|
Message: "User identity: missing (no user logged in)",
|
|
Hint: "run: lark-cli auth login --help",
|
|
}
|
|
}
|
|
|
|
id := Identity{
|
|
UserName: cfg.UserName,
|
|
OpenID: cfg.UserOpenId,
|
|
}
|
|
stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
|
|
if stored == nil {
|
|
id.Status = StatusMissing
|
|
id.Message = "User identity: missing (no token in keychain for " + cfg.UserOpenId + ")"
|
|
id.Hint = "run: lark-cli auth login --help"
|
|
return id
|
|
}
|
|
|
|
fillTokenFields(&id, stored)
|
|
switch larkauth.TokenStatus(stored) {
|
|
case "valid":
|
|
id.Status = StatusReady
|
|
id.Available = true
|
|
id.Message = "User identity: ready"
|
|
case "needs_refresh":
|
|
id.Status = StatusNeedsRefresh
|
|
id.Available = true
|
|
id.Message = "User identity: needs refresh (will auto-refresh on next user API call)"
|
|
default:
|
|
id.Status = StatusMissing
|
|
id.Message = "User identity: missing (refresh token expired)"
|
|
id.Hint = "run: lark-cli auth login --help"
|
|
return id
|
|
}
|
|
|
|
if !verify {
|
|
return id
|
|
}
|
|
|
|
markVerifyFailed := func(reason, hint string) Identity {
|
|
id.Status = StatusVerifyFailed
|
|
id.Available = false
|
|
id.Verified = boolPtr(false)
|
|
id.Message = "User identity: verify failed: " + reason
|
|
if hint != "" {
|
|
id.Hint = hint
|
|
}
|
|
return id
|
|
}
|
|
|
|
httpClient, err := f.HttpClient()
|
|
if err != nil {
|
|
return markVerifyFailed("create HTTP client: "+err.Error(), "")
|
|
}
|
|
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut))
|
|
if err != nil {
|
|
return markVerifyFailed("token unusable: "+err.Error(), "run: lark-cli auth login --help")
|
|
}
|
|
sdk, err := f.LarkClient()
|
|
if err != nil {
|
|
return markVerifyFailed("SDK init failed: "+err.Error(), "")
|
|
}
|
|
verifyCtx, cancel := context.WithTimeout(ctx, verifyTimeout)
|
|
defer cancel()
|
|
if err := larkauth.VerifyUserToken(verifyCtx, sdk, token); err != nil {
|
|
return markVerifyFailed("server rejected token: "+err.Error(), "run: lark-cli auth login --help")
|
|
}
|
|
|
|
id.Verified = boolPtr(true)
|
|
if id.Status == StatusReady {
|
|
id.Message = "User identity: ready"
|
|
} else {
|
|
id.Message = "User identity: needs refresh (server verification succeeded after refresh)"
|
|
}
|
|
return id
|
|
}
|
|
|
|
func resolveBotToken(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig) (string, error) {
|
|
if f == nil || f.Credential == nil {
|
|
return "", &credential.TokenUnavailableError{Type: credential.TokenTypeTAT}
|
|
}
|
|
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, cfg.AppID))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if result == nil || result.Token == "" {
|
|
return "", &credential.TokenUnavailableError{Type: credential.TokenTypeTAT}
|
|
}
|
|
return result.Token, nil
|
|
}
|
|
|
|
type botInfo struct {
|
|
OpenID string
|
|
AppName string
|
|
}
|
|
|
|
func fetchBotInfo(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, token string) (*botInfo, error) {
|
|
httpClient, err := f.HttpClient()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create HTTP client: %w", err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, verifyTimeout)
|
|
defer cancel()
|
|
url := strings.TrimRight(core.ResolveEndpoints(cfg.Brand).Open, "/") + "/open-apis/bot/v3/info"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
// /open-apis/bot/v3/info returns `{code, msg, bot: {...}}` — the bot
|
|
// payload is under "bot", not "data" as the newer Lark API convention.
|
|
var envelope struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data struct {
|
|
OpenID string `json:"open_id"`
|
|
AppName string `json:"app_name"`
|
|
} `json:"bot"`
|
|
}
|
|
parseErr := json.Unmarshal(body, &envelope)
|
|
|
|
if resp.StatusCode >= 400 {
|
|
// Lark error responses are usually `{code, msg}` envelopes even on
|
|
// non-2xx — surface them when present so callers see why bot auth
|
|
// was rejected, not just the bare HTTP code.
|
|
if parseErr == nil && envelope.Code != 0 {
|
|
return nil, fmt.Errorf("HTTP %d: [%d] %s", resp.StatusCode, envelope.Code, envelope.Msg)
|
|
}
|
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
}
|
|
if parseErr != nil {
|
|
return nil, fmt.Errorf("parse response: %w", parseErr)
|
|
}
|
|
if envelope.Code != 0 {
|
|
return nil, fmt.Errorf("[%d] %s", envelope.Code, envelope.Msg)
|
|
}
|
|
if envelope.Data.OpenID == "" {
|
|
return nil, errors.New("open_id is empty")
|
|
}
|
|
return &botInfo{OpenID: envelope.Data.OpenID, AppName: envelope.Data.AppName}, nil
|
|
}
|
|
|
|
func fillTokenFields(id *Identity, token *larkauth.StoredUAToken) {
|
|
id.TokenStatus = larkauth.TokenStatus(token)
|
|
id.Scope = token.Scope
|
|
id.ExpiresAt = formatMillis(token.ExpiresAt)
|
|
id.RefreshExpiresAt = formatMillis(token.RefreshExpiresAt)
|
|
id.GrantedAt = formatMillis(token.GrantedAt)
|
|
}
|
|
|
|
func formatMillis(ms int64) string {
|
|
if ms <= 0 {
|
|
return ""
|
|
}
|
|
return time.UnixMilli(ms).Format(time.RFC3339)
|
|
}
|
|
|
|
func StatusMessage(status string) string {
|
|
switch status {
|
|
case StatusNotConfigured:
|
|
return "not configured"
|
|
case StatusVerifyFailed:
|
|
return "verify failed"
|
|
case StatusNeedsRefresh:
|
|
return "needs refresh"
|
|
case StatusMissing:
|
|
return "missing"
|
|
default:
|
|
return status
|
|
}
|
|
}
|
|
|
|
func boolPtr(v bool) *bool {
|
|
return &v
|
|
}
|