mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
4dfe1b7dc0
Add Extended-only direct, credential-proxy, and platform-proxy modes while keeping Standard behavior and external-credential product details isolated behind source-neutral runtime capabilities. Fail closed across helper execution, proxy routing, streams, managed file transfers, and unsupported event flows. Harden system configuration, edition updates, and release publication. Cover Standard compatibility and Extended integration with contract tests, repository-local three-mode E2E, native trust checks, and CI and release gates.
194 lines
5.9 KiB
Go
194 lines
5.9 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
|
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
"github.com/spf13/cobra"
|
|
|
|
larkauth "github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/errclass"
|
|
"github.com/larksuite/cli/internal/recovery"
|
|
"github.com/larksuite/cli/internal/runtimeplan"
|
|
)
|
|
|
|
// NewCmdAuth creates the auth command with subcommands.
|
|
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
|
return newCmdAuth(f, nil)
|
|
}
|
|
|
|
// NewCmdAuthWithRecovery creates the auth command with a build-local recovery
|
|
// presenter while preserving NewCmdAuth's established function signature.
|
|
func NewCmdAuthWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
|
return newCmdAuth(f, projector)
|
|
}
|
|
|
|
func newCmdAuth(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "auth",
|
|
Short: "OAuth credentials and authorization management",
|
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
|
// Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first
|
|
// PersistentPreRun[E] found walking up the chain, so the root-level
|
|
// SilenceUsage=true would be skipped without this line.
|
|
cmd.SilenceUsage = true
|
|
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
|
|
},
|
|
}
|
|
cmdutil.DisableAuthCheck(cmd)
|
|
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
|
|
|
|
login := NewCmdAuthLogin(f, nil)
|
|
logout := NewCmdAuthLogout(f, nil)
|
|
status := newCmdAuthStatus(f, nil, projector)
|
|
scopes := NewCmdAuthScopes(f, nil)
|
|
list := newCmdAuthList(f, nil, projector)
|
|
check := newCmdAuthCheck(f, nil, projector)
|
|
qrcode := NewCmdAuthQRCode(f, nil)
|
|
for _, diagnostic := range []*cobra.Command{status, scopes, check, qrcode} {
|
|
cmdutil.SetRuntimeCapabilities(diagnostic)
|
|
}
|
|
cmd.AddCommand(login, logout, status, scopes, list, check, qrcode)
|
|
return cmd
|
|
}
|
|
|
|
// userInfoResponse is the API response for /open-apis/authen/v1/user_info.
|
|
type userInfoResponse struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data struct {
|
|
OpenID string `json:"open_id"`
|
|
Name string `json:"name"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
// getUserInfo fetches the current user's OpenID and name using the given access token.
|
|
func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (openId, name string, err error) {
|
|
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
|
|
HttpMethod: http.MethodGet,
|
|
ApiPath: larkauth.PathUserInfoV1,
|
|
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser},
|
|
}, larkcore.WithUserAccessToken(accessToken))
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
var resp userInfoResponse
|
|
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
|
return "", "", fmt.Errorf("failed to parse user info: %w", err)
|
|
}
|
|
if resp.Code != 0 {
|
|
return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
|
|
}
|
|
if resp.Data.OpenID == "" {
|
|
return "", "", fmt.Errorf("failed to get user info: missing open_id in response")
|
|
}
|
|
|
|
name = resp.Data.Name
|
|
if name == "" {
|
|
name = "(unknown)"
|
|
}
|
|
return resp.Data.OpenID, name, nil
|
|
}
|
|
|
|
// appInfo contains application information (owner, scopes).
|
|
type appInfo struct {
|
|
OwnerOpenId string
|
|
UserScopes []string
|
|
}
|
|
|
|
// appInfoResponse is the API response for /open-apis/application/v6/applications/:app_id.
|
|
type appInfoResponse struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data struct {
|
|
App struct {
|
|
Owner struct {
|
|
OwnerID string `json:"owner_id"`
|
|
} `json:"owner"`
|
|
CreatorID string `json:"creator_id"`
|
|
Scopes []struct {
|
|
Scope string `json:"scope"`
|
|
TokenTypes []string `json:"token_types"`
|
|
} `json:"scopes"`
|
|
} `json:"app"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
// getAppInfoFn is the package-level seam used by callers (scopes.go) so tests
|
|
// can substitute a fake without standing up a full SDK + httpmock pipeline.
|
|
// Mirrors the pollDeviceToken pattern in login.go.
|
|
var getAppInfoFn = getAppInfo
|
|
|
|
// getAppInfo queries app info from the Lark API.
|
|
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
|
ac, err := f.NewAPIClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
queryParams := make(larkcore.QueryParams)
|
|
queryParams.Set("lang", "zh_cn")
|
|
|
|
apiResp, err := ac.DoSDKRequest(ctx, &larkcore.ApiReq{
|
|
HttpMethod: http.MethodGet,
|
|
ApiPath: larkauth.ApplicationInfoPath(appId),
|
|
QueryParams: queryParams,
|
|
}, core.AsBot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp appInfoResponse
|
|
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
}
|
|
if resp.Code != 0 {
|
|
return nil, classifyAppInfoErr(apiResp.RawBody, resp.Code, resp.Msg, f, appId)
|
|
}
|
|
|
|
app := resp.Data.App
|
|
ownerOpenId := app.Owner.OwnerID
|
|
if ownerOpenId == "" {
|
|
ownerOpenId = app.CreatorID
|
|
}
|
|
|
|
var userScopes []string
|
|
for _, s := range app.Scopes {
|
|
if s.Scope == "" || !slices.Contains(s.TokenTypes, "user") {
|
|
continue
|
|
}
|
|
userScopes = append(userScopes, s.Scope)
|
|
}
|
|
|
|
return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil
|
|
}
|
|
|
|
// classifyAppInfoErr re-decodes the raw body so BuildAPIError sees the
|
|
// upstream `error` block — the typed appInfoResponse shape drops it.
|
|
func classifyAppInfoErr(rawBody []byte, code int, msg string, f *cmdutil.Factory, appId string) error {
|
|
var raw map[string]any
|
|
_ = json.Unmarshal(rawBody, &raw)
|
|
if raw == nil {
|
|
raw = map[string]any{}
|
|
}
|
|
raw["code"] = code
|
|
raw["msg"] = msg
|
|
cc := errclass.ClassifyContext{Identity: string(core.AsBot)}
|
|
if cfg, _ := f.Config(); cfg != nil {
|
|
cc.Brand = string(cfg.Brand)
|
|
cc.AppID = appId
|
|
}
|
|
return errclass.BuildAPIError(raw, cc)
|
|
}
|