From edf94993b0897971f33abbd0c8a9adf607875101 Mon Sep 17 00:00:00 2001 From: liujinkun2025 <77097548+liujinkun2025@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:35:56 +0800 Subject: [PATCH] feat: add user_token_getter_url, people can get user token from app manager without app secret, then use lark-cli --- cmd/auth/get_user_token_url.go.demo | 134 +++++++ cmd/auth/login.go | 269 ++++++++++++- cmd/auth/login_test.go | 593 ++++++++++++++++++++++++++++ cmd/config/config_test.go | 32 +- cmd/config/init.go | 122 ++++-- cmd/config/init_interactive.go | 43 +- go.mod | 2 +- go.sum | 4 +- internal/core/config.go | 94 +++-- internal/credential/types.go | 50 ++- skills/lark-shared/SKILL.md | 2 +- 11 files changed, 1212 insertions(+), 133 deletions(-) create mode 100644 cmd/auth/get_user_token_url.go.demo diff --git a/cmd/auth/get_user_token_url.go.demo b/cmd/auth/get_user_token_url.go.demo new file mode 100644 index 000000000..9f67ea6ec --- /dev/null +++ b/cmd/auth/get_user_token_url.go.demo @@ -0,0 +1,134 @@ +package auth + +// this file is a demo for user_token_getter_url + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/cloudwego/hertz/pkg/app" + "github.com/cloudwego/hertz/pkg/common/utils" + lark "github.com/larksuite/oapi-sdk-go/v3" + larkauthen "github.com/larksuite/oapi-sdk-go/v3/service/authen/v1" +) + +const ( + appID = "" + appSecret = "" + redirectURI + authHost = "open.feishu.cn/open.larksuite.com" +) + +var ( + larkClient = lark.NewClient(appID, appSecret) +) + +// UserAuth handles the initial step of the OAuth flow by redirecting the user to the Lark authorization page. +func UserAuth(ctx context.Context, c *app.RequestContext) { + state := c.Query("state") + if state == "" { + c.JSON(http.StatusBadRequest, utils.H{"error": "missing state"}) + return + } + + scope := c.Query("scope") + + authURLObj, _ := url.Parse(fmt.Sprintf("https://%s/open-apis/authen/v1/index", authHost)) + query := authURLObj.Query() + query.Set("redirect_uri", redirectURI) + query.Set("app_id", appID) + query.Set("state", state) + if scope != "" { + query.Set("scope", scope) + } + authURLObj.RawQuery = query.Encode() + + c.Redirect(http.StatusFound, []byte(authURLObj.String())) +} + +// OAuthCallback processes the OAuth callback from Lark, fetches the user access token, and sends it back to the local server. +func OAuthCallback(ctx context.Context, c *app.RequestContext) { + code := c.Query("code") + state := c.Query("state") + + if code == "" { + c.JSON(http.StatusBadRequest, utils.H{"error": "missing code"}) + return + } + + stateInt, err := strconv.Atoi(state) + if err != nil { + c.JSON(http.StatusBadRequest, utils.H{"error": "invalid state parameter, must be integer"}) + return + } + + // get user_access_token + req := larkauthen.NewCreateAccessTokenReqBuilder(). + Body(larkauthen.NewCreateAccessTokenReqBodyBuilder(). + GrantType("authorization_code"). + Code(code). + Build()). + Build() + + resp, err := larkClient.Authen.AccessToken.Create(ctx, req) + if err != nil { + c.JSON(http.StatusInternalServerError, utils.H{"error": "failed to get access token", "detail": err.Error()}) + return + } + + if !resp.Success() { + c.JSON(http.StatusInternalServerError, utils.H{"error": "feishu API returned error", "code": resp.Code, "msg": resp.Msg}) + return + } + + data := resp.Data + if data == nil || data.AccessToken == nil { + c.JSON(http.StatusInternalServerError, utils.H{"error": "empty data in response"}) + return + } + + // TODO check user permission or scope + + dataBytes, err := json.Marshal(data) + if err != nil { + c.JSON(http.StatusInternalServerError, utils.H{"error": "failed to marshal data"}) + return + } + + html := fmt.Sprintf(` + +
+ +Sending token...
+ + +`, stateInt, string(dataBytes)) + + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html)) +} diff --git a/cmd/auth/login.go b/cmd/auth/login.go index add8762bb..67c7ace02 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -11,6 +11,14 @@ import ( "strings" "time" + "io" + "net" + "net/http" + "net/url" + "os/exec" + "runtime" + "strconv" + "github.com/spf13/cobra" larkauth "github.com/larksuite/cli/internal/auth" @@ -24,17 +32,20 @@ import ( // LoginOptions holds all inputs for auth login. type LoginOptions struct { - Factory *cmdutil.Factory - Ctx context.Context - JSON bool - Scope string - Recommend bool - Domains []string - NoWait bool - DeviceCode string + Factory *cmdutil.Factory // Factory is the cmdutil.Factory + Ctx context.Context // Ctx is the context for the command + JSON bool // JSON specifies whether to output in JSON format + Scope string // Scope specifies the required scopes + Recommend bool // Recommend specifies whether to recommend standard scopes + Domains []string // Domains holds the requested domain names + NoWait bool // NoWait tells the command not to wait for auth polling + DeviceCode string // DeviceCode provides the manual device code if any } -var pollDeviceToken = larkauth.PollDeviceToken +var ( + pollDeviceToken = larkauth.PollDeviceToken + openBrowserFn = openBrowser +) // NewCmdAuthLogin creates the auth login subcommand. func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { @@ -214,6 +225,10 @@ func authLoginRun(opts *LoginOptions) error { finalScope = strings.Join(candidateScopes, " ") } + if config.UserTokenGetterUrl != "" && config.AppSecret == "" { + return authLoginViaGetter(opts, config, finalScope, msg, log) + } + // Step 1: Request device authorization httpClient, err := f.HttpClient() if err != nil { @@ -328,6 +343,238 @@ func authLoginRun(opts *LoginOptions) error { return nil } +// UserTokenData structure for capturing user token response from auth getter. +type UserTokenData struct { + AccessToken *string `json:"access_token,omitempty"` // AccessToken 用于获取用户资源 + TokenType *string `json:"token_type,omitempty"` // TokenType token 类型 + ExpiresIn *int `json:"expires_in,omitempty"` // ExpiresIn access_token 的有效期,单位: 秒 + Name *string `json:"name,omitempty"` // Name 用户姓名 + EnName *string `json:"en_name,omitempty"` // EnName 用户英文名称 + AvatarUrl *string `json:"avatar_url,omitempty"` // AvatarUrl 用户头像 + AvatarThumb *string `json:"avatar_thumb,omitempty"` // AvatarThumb 用户头像 72x72 + AvatarMiddle *string `json:"avatar_middle,omitempty"` // AvatarMiddle 用户头像 240x240 + AvatarBig *string `json:"avatar_big,omitempty"` // AvatarBig 用户头像 640x640 + OpenId *string `json:"open_id,omitempty"` // OpenId 用户在应用内的唯一标识 + UnionId *string `json:"union_id,omitempty"` // UnionId 用户统一ID + UserId *string `json:"user_id,omitempty"` // UserId 用户 user_id + TenantKey *string `json:"tenant_key,omitempty"` // TenantKey 当前企业标识 +} + +// authLoginViaGetter executes the login command logic via user token getter. +func authLoginViaGetter(opts *LoginOptions, config *core.CliConfig, finalScope string, msg *loginMsg, log func(string, ...interface{})) error { + f := opts.Factory + token, err := fetchTokenViaGetter(opts.Ctx, config.UserTokenGetterUrl, finalScope, log) + if err != nil { + return output.ErrAuth("failed to fetch user token via url: %v", err) + } + + var gt UserTokenData + if err := json.Unmarshal([]byte(token), >); err != nil { + return output.ErrAuth("failed to unmarshal token JSON: %v", err) + } + + if gt.AccessToken == nil || *gt.AccessToken == "" { + return output.ErrAuth("authorization succeeded but no access_token returned") + } + + openId := "" + if gt.OpenId != nil { + openId = *gt.OpenId + } + userName := "" + if gt.Name != nil { + userName = *gt.Name + } + expiresIn := 0 + if gt.ExpiresIn != nil { + expiresIn = *gt.ExpiresIn + } + + // 如果没有 open_id/name,依然需要通过 SDK 获取 + if openId == "" || userName == "" { + log(msg.AuthSuccess) + sdk, err := f.LarkClient() + if err != nil { + return output.ErrAuth("failed to get SDK: %v", err) + } + // NOTE: getUserInfo requires access token + fetchedOpenId, fetchedUserName, err := getUserInfo(opts.Ctx, sdk, *gt.AccessToken) + if err != nil { + return output.ErrAuth("failed to get user info: %v", err) + } + if openId == "" { + openId = fetchedOpenId + } + if userName == "" { + userName = fetchedUserName + } + } else { + log(msg.AuthSuccess) + } + + now := time.Now().UnixMilli() + expiresAt := now + int64(expiresIn)*1000 + if expiresIn <= 0 { + expiresAt = now + 7200*1000 // 默认 2h + } + + storedToken := &larkauth.StoredUAToken{ + UserOpenId: openId, + AppId: config.AppID, + AccessToken: *gt.AccessToken, + RefreshToken: "", // 这种方式不支持 refresh token + ExpiresAt: expiresAt, + RefreshExpiresAt: expiresAt, + Scope: "", // 这种方式暂不解析 scope + GrantedAt: now, + } + + if err := larkauth.SetStoredToken(storedToken); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err) + } + + if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil { + _ = larkauth.RemoveStoredToken(config.AppID, openId) + return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err) + } + + writeLoginSuccess(opts, msg, f, openId, userName, nil) + return nil +} + +// fetchTokenViaGetter retrieves a user access token by opening a local server to receive the token via an OAuth callback. +func fetchTokenViaGetter(ctx context.Context, getterURL string, scope string, log func(string, ...interface{})) (string, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", fmt.Errorf("failed to start local server for token retrieval: %w", err) + } + if listener == nil { + return "", fmt.Errorf("failed to start local server for token retrieval: %w", err) + } + port := listener.Addr().(*net.TCPAddr).Port + + tokenCh := make(chan string, 1) + errCh := make(chan error, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/user_access_token", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + var tokenData string + if r.Method == http.MethodPost { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + tokenData = string(body) + } else { + tokenData = r.URL.Query().Get("token") + } + + if tokenData == "" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "Missing token data
") + select { + case errCh <- fmt.Errorf("missing token data in callback request"): + default: + } + return + } + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, ` +You can close this page and return to the terminal.
`) + + select { + case tokenCh <- tokenData: + default: + } + }) + + server := &http.Server{Handler: mux} + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + select { + case errCh <- fmt.Errorf("local server error: %w", err): + default: + } + } + }() + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + server.Shutdown(shutdownCtx) + }() + + log("Waiting for authorization, local server started on http://127.0.0.1:%d/user_access_token...", port) + + u, err := url.Parse(getterURL) + if err != nil { + return "", fmt.Errorf("failed to parse getterURL: %w", err) + } + q := u.Query() + q.Set("state", strconv.Itoa(port)) + if scope != "" { + q.Set("scope", scope) + } + u.RawQuery = q.Encode() + finalURL := u.String() + + if err := openBrowserFn(ctx, finalURL); err != nil { + log("Could not open browser automatically. Please visit the following link manually:") + } else { + log("Opening browser to get token...") + log("If the browser does not open automatically, please visit:") + } + log(" %s\n", finalURL) + + timer := time.NewTimer(5 * time.Minute) + defer timer.Stop() + + select { + case <-ctx.Done(): + return "", fmt.Errorf("context canceled: %w", ctx.Err()) + case token := <-tokenCh: + return token, nil + case err := <-errCh: + return "", err + case <-timer.C: + return "", fmt.Errorf("timeout waiting for token callback (5 minutes)") + } +} + +// openBrowser opens the specified URL in the user's default browser. +// It tries to use system-specific commands depending on the OS (linux, darwin, windows). +func openBrowser(ctx context.Context, url string) error { + // 简单的跨平台打开浏览器实现 + var err error + switch runtime.GOOS { + case "linux": + err = exec.CommandContext(ctx, "xdg-open", url).Start() + case "darwin": + err = exec.CommandContext(ctx, "open", url).Start() + case "windows": + err = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", url).Start() + default: + // Go fallback + err = exec.CommandContext(ctx, "open", url).Start() + if err != nil { + err = exec.CommandContext(ctx, "xdg-open", url).Start() + } + } + return err +} + // authLoginPollDeviceCode resumes the device flow by polling with a device code // obtained from a previous --no-wait call. func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *loginMsg, log func(string, ...interface{})) error { @@ -404,6 +651,8 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo return nil } +// syncLoginUserToProfile updates the profile configuration to only contain the provided user. +// It removes any old stored tokens for other users associated with this app ID. func syncLoginUserToProfile(profileName, appID, openID, userName string) error { multi, err := core.LoadMultiAppConfig() if err != nil { @@ -429,6 +678,8 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error { return nil } +// findProfileByName retrieves an app configuration by its profile name. +// It returns nil if the profile is not found. func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig { for i := range multi.Apps { if multi.Apps[i].ProfileName() == profileName { diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index a3b84af37..9cf8ddb63 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -7,11 +7,15 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" + "net" "net/http" + "net/url" "sort" "strings" "testing" + "time" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" @@ -24,10 +28,12 @@ import ( type failWriter struct{} +// Write implements the io.Writer interface but always fails. func (failWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") } +// TestSuggestDomain_PrefixMatch tests the corresponding functionality. func TestSuggestDomain_PrefixMatch(t *testing.T) { known := map[string]bool{ "calendar": true, @@ -47,6 +53,7 @@ func TestSuggestDomain_PrefixMatch(t *testing.T) { } } +// TestSuggestDomain_NoMatch tests the corresponding functionality. func TestSuggestDomain_NoMatch(t *testing.T) { known := map[string]bool{ "calendar": true, @@ -58,6 +65,7 @@ func TestSuggestDomain_NoMatch(t *testing.T) { } } +// TestSuggestDomain_ExactMatch tests the corresponding functionality. func TestSuggestDomain_ExactMatch(t *testing.T) { known := map[string]bool{ "calendar": true, @@ -69,6 +77,7 @@ func TestSuggestDomain_ExactMatch(t *testing.T) { } } +// TestShortcutSupportsIdentity_DefaultUser tests the corresponding functionality. func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) { // Empty AuthTypes defaults to ["user"] sc := common.Shortcut{AuthTypes: nil} @@ -80,6 +89,7 @@ func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) { } } +// TestShortcutSupportsIdentity_ExplicitTypes tests the corresponding functionality. func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) { sc := common.Shortcut{AuthTypes: []string{"user", "bot"}} if !shortcutSupportsIdentity(sc, "user") { @@ -93,6 +103,7 @@ func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) { } } +// TestShortcutSupportsIdentity_BotOnly tests the corresponding functionality. func TestShortcutSupportsIdentity_BotOnly(t *testing.T) { sc := common.Shortcut{AuthTypes: []string{"bot"}} if shortcutSupportsIdentity(sc, "user") { @@ -103,6 +114,7 @@ func TestShortcutSupportsIdentity_BotOnly(t *testing.T) { } } +// TestCompleteDomain tests the corresponding functionality. func TestCompleteDomain(t *testing.T) { projects := registry.ListFromMetaProjects() if len(projects) == 0 { @@ -128,6 +140,7 @@ func TestCompleteDomain(t *testing.T) { } } +// TestCompleteDomain_CommaSeparated tests the corresponding functionality. func TestCompleteDomain_CommaSeparated(t *testing.T) { projects := registry.ListFromMetaProjects() if len(projects) == 0 { @@ -143,6 +156,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) { } } +// TestAllKnownDomains tests the corresponding functionality. func TestAllKnownDomains(t *testing.T) { domains := allKnownDomains() if len(domains) == 0 { @@ -157,6 +171,7 @@ func TestAllKnownDomains(t *testing.T) { } } +// TestSortedKnownDomains tests the corresponding functionality. func TestSortedKnownDomains(t *testing.T) { sorted := sortedKnownDomains() if len(sorted) == 0 { @@ -174,6 +189,7 @@ func TestSortedKnownDomains(t *testing.T) { } } +// TestGetShortcutOnlyDomainNames_HaveDescriptions tests the corresponding functionality. func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) { for _, name := range getShortcutOnlyDomainNames() { zhDesc := registry.GetServiceDescription(name, "zh") @@ -187,6 +203,7 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) { } } +// TestCollectScopesForDomains tests the corresponding functionality. func TestCollectScopesForDomains(t *testing.T) { projects := registry.ListFromMetaProjects() if len(projects) == 0 { @@ -219,6 +236,7 @@ func TestCollectScopesForDomains(t *testing.T) { } } +// TestCollectScopesForDomains_NonexistentDomain tests the corresponding functionality. func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) { scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user") if len(scopes) != 0 { @@ -226,6 +244,7 @@ func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) { } } +// TestGetDomainMetadata_IncludesFromMeta tests the corresponding functionality. func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) { domains := getDomainMetadata("zh") nameSet := make(map[string]bool) @@ -241,6 +260,7 @@ func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) { } } +// TestGetDomainMetadata_IncludesShortcutOnlyDomains tests the corresponding functionality. func TestGetDomainMetadata_IncludesShortcutOnlyDomains(t *testing.T) { domains := getDomainMetadata("zh") nameSet := make(map[string]bool) @@ -255,6 +275,7 @@ func TestGetDomainMetadata_IncludesShortcutOnlyDomains(t *testing.T) { } } +// TestGetDomainMetadata_Sorted tests the corresponding functionality. func TestGetDomainMetadata_Sorted(t *testing.T) { domains := getDomainMetadata("zh") for i := 1; i < len(domains); i++ { @@ -264,6 +285,7 @@ func TestGetDomainMetadata_Sorted(t *testing.T) { } } +// TestGetDomainMetadata_HasTitleAndDescription tests the corresponding functionality. func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) { domains := getDomainMetadata("zh") for _, dm := range domains { @@ -273,6 +295,7 @@ func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) { } } +// TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint tests the corresponding functionality. func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, @@ -295,6 +318,7 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { } } +// TestEnsureRequestedScopesGranted tests the corresponding functionality. func TestEnsureRequestedScopesGranted(t *testing.T) { issue := ensureRequestedScopesGranted("im:message:send im:message:reply", "im:message:reply", getLoginMsg("en"), nil) if issue == nil { @@ -313,6 +337,7 @@ func TestEnsureRequestedScopesGranted(t *testing.T) { } } +// TestBuildLoginScopeSummary tests the corresponding functionality. func TestBuildLoginScopeSummary(t *testing.T) { summary := buildLoginScopeSummary("im:message:send im:message:reply im:message:send", "im:message:reply", "im:message:send im:message:reply im:chat:read") if got := strings.Join(summary.Requested, " "); got != "im:message:send im:message:reply" { @@ -332,6 +357,7 @@ func TestBuildLoginScopeSummary(t *testing.T) { } } +// TestWriteLoginSuccess_JSONIncludesScopeDiff tests the corresponding functionality. func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, nil) @@ -360,6 +386,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) { } } +// TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess tests the corresponding functionality. func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) { f, _, stderr, _ := cmdutil.TestFactory(t, nil) err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{ @@ -399,6 +426,7 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) { } } +// TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess tests the corresponding functionality. func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, nil) err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{ @@ -433,6 +461,7 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) { } } +// TestWriteLoginSuccess_JSONEmptySlicesNotNull tests the corresponding functionality. func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, nil) @@ -455,6 +484,7 @@ func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) { } } +// TestWriteLoginSuccess_TextOutputScenarios tests the corresponding functionality. func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) { tests := []struct { name string @@ -540,6 +570,7 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) { } } +// TestBuildLoginScopeSummary_WithMissingScopes tests the corresponding functionality. func TestBuildLoginScopeSummary_WithMissingScopes(t *testing.T) { summary := buildLoginScopeSummary("im:message:send im:message:reply", "im:message:reply", "im:message:reply") if got := strings.Join(summary.NewlyGranted, " "); got != "" { @@ -553,6 +584,7 @@ func TestBuildLoginScopeSummary_WithMissingScopes(t *testing.T) { } } +// TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess tests the corresponding functionality. func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) { keyring.MockInit() setupLoginConfigDir(t) @@ -666,6 +698,7 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) } } +// TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes tests the corresponding functionality. func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) { keyring.MockInit() setupLoginConfigDir(t) @@ -767,6 +800,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) { } } +// TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScopes tests the corresponding functionality. func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScopes(t *testing.T) { f, _, stderr, _ := cmdutil.TestFactory(t, nil) @@ -792,6 +826,7 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope } } +// TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache tests the corresponding functionality. func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) { keyring.MockInit() setupLoginConfigDir(t) @@ -829,6 +864,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) { } } +// TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError tests the corresponding functionality. func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) { f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ ProfileName: "default", @@ -866,6 +902,7 @@ func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) { } } +// TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError tests the corresponding functionality. func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) { f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ ProfileName: "default", @@ -904,6 +941,559 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t * } } +// TestFetchTokenViaGetter_Success tests the corresponding functionality. +func TestFetchTokenViaGetter_Success(t *testing.T) { + // Setup a mock getter server + getterMux := http.NewServeMux() + getterMux.HandleFunc("/getter", func(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + + // Send token back to the callback URL asynchronously + go func() { + // Small delay to ensure the local server is fully started + // (although it should be since fetchTokenViaGetter waits) + http.Get(fmt.Sprintf("http://127.0.0.1:%s/user_access_token?token=mock_token_data", state)) + }() + + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "Mock Getter") + }) + + getterServer := &http.Server{Addr: "127.0.0.1:0", Handler: getterMux} + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to start mock getter server: %v", err) + } + go getterServer.Serve(listener) + defer getterServer.Close() + + getterURL := fmt.Sprintf("http://%s/getter", listener.Addr().String()) + + // We need to temporarily disable openBrowser so it doesn't try to open a real browser + // Just for this test + originalOpenBrowser := openBrowserFn + t.Cleanup(func() { openBrowserFn = originalOpenBrowser }) + openBrowserFn = func(ctx context.Context, u string) error { + // Instead of opening browser, directly query the getter server + go func() { + // Small delay to ensure the mock HTTP server is ready + time.Sleep(10 * time.Millisecond) + http.Get(u) + }() + return nil + } + + var logs []string + logFn := func(format string, args ...interface{}) { + logs = append(logs, fmt.Sprintf(format, args...)) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Open a goroutine to act as the browser for this specific test + // We intercept the log messages to find out what port to hit + + token, err := fetchTokenViaGetter(ctx, getterURL, "test_scope", logFn) + + if err != nil { + t.Fatalf("fetchTokenViaGetter failed: %v", err) + } + + if token != "mock_token_data" { + t.Errorf("Expected token %q, got %q", "mock_token_data", token) + } +} + +// TestFetchTokenViaGetter_MissingToken tests the corresponding functionality. +func TestFetchTokenViaGetter_MissingToken(t *testing.T) { + // Setup a mock getter server that sends empty token + getterMux := http.NewServeMux() + getterMux.HandleFunc("/getter", func(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + + go func() { + http.Get(fmt.Sprintf("http://127.0.0.1:%s/user_access_token", state)) + }() + + w.WriteHeader(http.StatusOK) + }) + + getterServer := &http.Server{Addr: "127.0.0.1:0", Handler: getterMux} + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to start mock getter server: %v", err) + } + go getterServer.Serve(listener) + defer getterServer.Close() + + getterURL := fmt.Sprintf("http://%s/getter", listener.Addr().String()) + + // We need to temporarily disable openBrowser so it doesn't try to open a real browser + // Just for this test + originalOpenBrowser := openBrowserFn + openBrowserFn = func(ctx context.Context, u string) error { + go func() { + // Small delay to ensure the mock HTTP server is ready + time.Sleep(10 * time.Millisecond) + http.Get(u) + }() + return nil + } + defer func() { openBrowserFn = originalOpenBrowser }() + + var logs []string + logFn := func(format string, args ...interface{}) { + logs = append(logs, fmt.Sprintf(format, args...)) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err = fetchTokenViaGetter(ctx, getterURL, "test_scope", logFn) + + if err == nil { + t.Fatal("fetchTokenViaGetter should fail with missing token") + } + + if !strings.Contains(err.Error(), "missing token data in callback request") { + t.Errorf("Expected missing token error, got: %v", err) + } +} + +// TestFetchTokenViaGetter_Timeout tests the corresponding functionality. +func TestFetchTokenViaGetter_Timeout(t *testing.T) { + // Setup a mock getter server that does nothing + getterMux := http.NewServeMux() + getterMux.HandleFunc("/getter", func(w http.ResponseWriter, r *http.Request) { + // Do nothing, wait for timeout + w.WriteHeader(http.StatusOK) + }) + + getterServer := &http.Server{Addr: "127.0.0.1:0", Handler: getterMux} + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to start mock getter server: %v", err) + } + go getterServer.Serve(listener) + defer getterServer.Close() + + getterURL := fmt.Sprintf("http://%s/getter", listener.Addr().String()) + + // We need to temporarily disable openBrowser so it doesn't try to open a real browser + // Just for this test + originalOpenBrowser := openBrowserFn + openBrowserFn = func(ctx context.Context, u string) error { + go func() { + // Small delay to ensure the mock HTTP server is ready + time.Sleep(10 * time.Millisecond) + http.Get(u) + }() + return nil + } + defer func() { openBrowserFn = originalOpenBrowser }() + + var logs []string + logFn := func(format string, args ...interface{}) { + logs = append(logs, fmt.Sprintf(format, args...)) + } + + // Create a short timeout context to trigger the timeout quickly + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, err = fetchTokenViaGetter(ctx, getterURL, "test_scope", logFn) + + if err == nil { + t.Fatal("fetchTokenViaGetter should fail with timeout") + } + + if !strings.Contains(err.Error(), "timeout waiting for token callback") && !strings.Contains(err.Error(), "context canceled") { + t.Errorf("Expected timeout or context canceled error, got: %v", err) + } +} + +// TestAuthLoginViaGetter_SuccessWithAllFields tests the corresponding functionality. +func TestAuthLoginViaGetter_SuccessWithAllFields(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + t.Setenv("HOME", t.TempDir()) + + multi := &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{ + {Name: "default", AppId: "cli_test", UserTokenGetterUrl: "http://example.com/getter"}, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + + f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + UserTokenGetterUrl: "http://example.com/getter", + Brand: core.BrandFeishu, + }) + + // Instead of hitting the real fetchTokenViaGetter which would block, + // we will run authLoginRun but we need a way to mock fetchTokenViaGetter. + // We can't easily mock fetchTokenViaGetter without changing the production code to accept a mock. + // So we test authLoginViaGetter directly, and mock the HTTP server for the callback. + + // We simulate what authLoginRun does but use a mocked local server that answers fetchTokenViaGetter + + // Mock openBrowserFn to prevent it from trying to open a real browser + originalOpenBrowser := openBrowserFn + t.Cleanup(func() { openBrowserFn = originalOpenBrowser }) + openBrowserFn = func(ctx context.Context, u string) error { + go func() { + // Small delay to ensure the mock HTTP server is ready + time.Sleep(10 * time.Millisecond) + http.Get(u) + }() + return nil + } + + // Setup a mock getter server + getterMux := http.NewServeMux() + getterMux.HandleFunc("/getter", func(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + + go func() { + tokenData := `{"access_token":"mock_user_access_token","expires_in":7200,"name":"Mock User","open_id":"ou_mock"}` + + // Try a few times in case the local server isn't up yet + for i := 0; i < 5; i++ { + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%s/user_access_token?token=%s", state, url.QueryEscape(tokenData))) + if err == nil && resp.StatusCode == http.StatusOK { + resp.Body.Close() + break + } + time.Sleep(100 * time.Millisecond) + } + }() + + w.WriteHeader(http.StatusOK) + }) + + getterServer := &http.Server{Addr: "127.0.0.1:0", Handler: getterMux} + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to start mock getter server: %v", err) + } + go getterServer.Serve(listener) + defer getterServer.Close() + + getterURL := fmt.Sprintf("http://%s/getter", listener.Addr().String()) + + // Update config to use our mock getter server + config := &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + UserTokenGetterUrl: getterURL, + Brand: core.BrandFeishu, + } + + opts := &LoginOptions{ + Factory: f, + Ctx: context.Background(), + Scope: "test_scope", + } + + var logs []string + logFn := func(format string, args ...interface{}) { + logs = append(logs, fmt.Sprintf(format, args...)) + } + + err = authLoginViaGetter(opts, config, "test_scope", getLoginMsg("en"), logFn) + + if err != nil { + t.Fatalf("authLoginViaGetter failed: %v", err) + } + + // Check that the token was stored correctly + stored := larkauth.GetStoredToken("cli_test", "ou_mock") + if stored == nil { + t.Fatal("expected token to be stored") + } + if stored.AccessToken != "mock_user_access_token" { + t.Errorf("expected access token 'mock_user_access_token', got '%s'", stored.AccessToken) + } + + // Check the profile was updated + cfg, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig() error = %v", err) + } + if len(cfg.Apps) != 1 || len(cfg.Apps[0].Users) != 1 { + t.Fatalf("unexpected users in config: %#v", cfg.Apps) + } + if cfg.Apps[0].Users[0].UserOpenId != "ou_mock" { + t.Fatalf("stored user open id = %q", cfg.Apps[0].Users[0].UserOpenId) + } + if cfg.Apps[0].Users[0].UserName != "Mock User" { + t.Fatalf("stored user name = %q", cfg.Apps[0].Users[0].UserName) + } + + // stdout shouldn't have much for text mode, stderr is tested via writeLoginSuccess elsewhere + _ = stdout + _ = stderr +} + +// TestAuthLoginViaGetter_FallbackGetUserInfo tests the corresponding functionality. +func TestAuthLoginViaGetter_FallbackGetUserInfo(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + t.Setenv("HOME", t.TempDir()) + + multi := &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{ + {Name: "default", AppId: "cli_test", UserTokenGetterUrl: "http://example.com/getter"}, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + UserTokenGetterUrl: "http://example.com/getter", + Brand: core.BrandFeishu, + }) + + // Setup mock for user info since the getter won't return open_id and name + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: larkauth.PathUserInfoV1, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "open_id": "ou_fallback", + "name": "Fallback User", + }, + }, + }) + + // Mock openBrowserFn to prevent it from trying to open a real browser + originalOpenBrowser := openBrowserFn + t.Cleanup(func() { openBrowserFn = originalOpenBrowser }) + openBrowserFn = func(ctx context.Context, u string) error { + go func() { + // Small delay to ensure the mock HTTP server is ready + time.Sleep(10 * time.Millisecond) + http.Get(u) + }() + return nil + } + + // Setup a mock getter server that returns ONLY access token + getterMux := http.NewServeMux() + getterMux.HandleFunc("/getter", func(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + + go func() { + tokenData := `{"access_token":"mock_user_access_token_only"}` + for i := 0; i < 5; i++ { + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%s/user_access_token?token=%s", state, url.QueryEscape(tokenData))) + if err == nil && resp.StatusCode == http.StatusOK { + resp.Body.Close() + break + } + time.Sleep(100 * time.Millisecond) + } + }() + + w.WriteHeader(http.StatusOK) + }) + + getterServer := &http.Server{Addr: "127.0.0.1:0", Handler: getterMux} + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to start mock getter server: %v", err) + } + go getterServer.Serve(listener) + defer getterServer.Close() + + getterURL := fmt.Sprintf("http://%s/getter", listener.Addr().String()) + + config := &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + UserTokenGetterUrl: getterURL, + Brand: core.BrandFeishu, + } + + opts := &LoginOptions{ + Factory: f, + Ctx: context.Background(), + Scope: "test_scope", + } + + var logs []string + logFn := func(format string, args ...interface{}) { + logs = append(logs, fmt.Sprintf(format, args...)) + } + + err = authLoginViaGetter(opts, config, "test_scope", getLoginMsg("en"), logFn) + + if err != nil { + t.Fatalf("authLoginViaGetter failed: %v", err) + } + + // Check that the token was stored with the fallback info + stored := larkauth.GetStoredToken("cli_test", "ou_fallback") + if stored == nil { + t.Fatal("expected token to be stored") + } + if stored.AccessToken != "mock_user_access_token_only" { + t.Errorf("expected access token 'mock_user_access_token_only', got '%s'", stored.AccessToken) + } + + // Check the profile was updated + cfg, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig() error = %v", err) + } + if len(cfg.Apps) != 1 || len(cfg.Apps[0].Users) != 1 { + t.Fatalf("unexpected users in config: %#v", cfg.Apps) + } + if cfg.Apps[0].Users[0].UserOpenId != "ou_fallback" { + t.Fatalf("stored user open id = %q", cfg.Apps[0].Users[0].UserOpenId) + } + if cfg.Apps[0].Users[0].UserName != "Fallback User" { + t.Fatalf("stored user name = %q", cfg.Apps[0].Users[0].UserName) + } +} + +// TestAuthLoginViaGetter_InvalidJSON tests the corresponding functionality. +func TestAuthLoginViaGetter_InvalidJSON(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + // Mock openBrowserFn to prevent it from trying to open a real browser + originalOpenBrowser := openBrowserFn + t.Cleanup(func() { openBrowserFn = originalOpenBrowser }) + openBrowserFn = func(ctx context.Context, u string) error { + go func() { + // Small delay to ensure the mock HTTP server is ready + time.Sleep(10 * time.Millisecond) + http.Get(u) + }() + return nil + } + + getterMux := http.NewServeMux() + getterMux.HandleFunc("/getter", func(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + go func() { + tokenData := `invalid_json` + for i := 0; i < 5; i++ { + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%s/user_access_token?token=%s", state, url.QueryEscape(tokenData))) + if err == nil { + resp.Body.Close() + break + } + time.Sleep(100 * time.Millisecond) + } + }() + w.WriteHeader(http.StatusOK) + }) + + getterServer := &http.Server{Addr: "127.0.0.1:0", Handler: getterMux} + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to start mock getter server: %v", err) + } + go getterServer.Serve(listener) + defer getterServer.Close() + + getterURL := fmt.Sprintf("http://%s/getter", listener.Addr().String()) + + config := &core.CliConfig{ + UserTokenGetterUrl: getterURL, + } + + opts := &LoginOptions{ + Factory: f, + Ctx: context.Background(), + } + + err = authLoginViaGetter(opts, config, "", getLoginMsg("en"), func(string, ...interface{}) {}) + + if err == nil { + t.Fatal("expected error due to invalid json") + } + if !strings.Contains(err.Error(), "failed to unmarshal token JSON") { + t.Errorf("expected json unmarshal error, got: %v", err) + } +} + +// TestAuthLoginViaGetter_NoAccessToken tests the corresponding functionality. +func TestAuthLoginViaGetter_NoAccessToken(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + // Mock openBrowserFn to prevent it from trying to open a real browser + originalOpenBrowser := openBrowserFn + t.Cleanup(func() { openBrowserFn = originalOpenBrowser }) + openBrowserFn = func(ctx context.Context, u string) error { + go func() { + // Small delay to ensure the mock HTTP server is ready + time.Sleep(10 * time.Millisecond) + http.Get(u) + }() + return nil + } + + getterMux := http.NewServeMux() + getterMux.HandleFunc("/getter", func(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + go func() { + tokenData := `{"name":"User Without Token"}` + for i := 0; i < 5; i++ { + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%s/user_access_token?token=%s", state, url.QueryEscape(tokenData))) + if err == nil { + resp.Body.Close() + break + } + time.Sleep(100 * time.Millisecond) + } + }() + w.WriteHeader(http.StatusOK) + }) + + getterServer := &http.Server{Addr: "127.0.0.1:0", Handler: getterMux} + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to start mock getter server: %v", err) + } + go getterServer.Serve(listener) + defer getterServer.Close() + + getterURL := fmt.Sprintf("http://%s/getter", listener.Addr().String()) + + config := &core.CliConfig{ + UserTokenGetterUrl: getterURL, + } + + opts := &LoginOptions{ + Factory: f, + Ctx: context.Background(), + } + + err = authLoginViaGetter(opts, config, "", getLoginMsg("en"), func(string, ...interface{}) {}) + + if err == nil { + t.Fatal("expected error due to missing access token") + } + if !strings.Contains(err.Error(), "no access_token returned") { + t.Errorf("expected no access_token error, got: %v", err) + } +} + +// TestGetDomainMetadata_ExcludesEvent tests the corresponding functionality. func TestGetDomainMetadata_ExcludesEvent(t *testing.T) { domains := getDomainMetadata("zh") for _, dm := range domains { @@ -913,6 +1503,7 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) { } } +// TestAllKnownDomains_ExcludesAuthDomainChildren tests the corresponding functionality. func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) { domains := allKnownDomains() if domains["whiteboard"] { @@ -923,6 +1514,7 @@ func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) { } } +// TestCollectScopesForDomains_ExpandsAuthDomainChildren tests the corresponding functionality. func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) { scopes := collectScopesForDomains([]string{"docs"}, "user") // docs domain should include whiteboard shortcut scopes (board:whiteboard:*) @@ -938,6 +1530,7 @@ func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) { } } +// TestGetDomainMetadata_ExcludesAuthDomainChildren tests the corresponding functionality. func TestGetDomainMetadata_ExcludesAuthDomainChildren(t *testing.T) { domains := getDomainMetadata("zh") for _, dm := range domains { diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index 300865548..e078544ec 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -22,21 +22,32 @@ import ( type noopConfigKeychain struct{} +// Get retrieves a stored value. func (n *noopConfigKeychain) Get(service, account string) (string, error) { return "", nil } + +// Set stores a value. func (n *noopConfigKeychain) Set(service, account, value string) error { return nil } + +// Remove deletes a stored value. func (n *noopConfigKeychain) Remove(service, account string) error { return nil } type recordingConfigKeychain struct { removed []string } +// Get retrieves a stored value. func (r *recordingConfigKeychain) Get(service, account string) (string, error) { return "", nil } + +// Set stores a value. func (r *recordingConfigKeychain) Set(service, account, value string) error { return nil } + +// Remove deletes a stored value and records the removal. func (r *recordingConfigKeychain) Remove(service, account string) error { r.removed = append(r.removed, service+":"+account) return nil } +// TestConfigInitCmd_FlagParsing tests the corresponding functionality. func TestConfigInitCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) f.IOStreams.In = strings.NewReader("secret123\n") @@ -62,6 +73,7 @@ func TestConfigInitCmd_FlagParsing(t *testing.T) { } } +// TestConfigShowCmd_FlagParsing tests the corresponding functionality. func TestConfigShowCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, @@ -81,6 +93,7 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) { } } +// TestConfigShowRun_NotConfiguredReturnsStructuredError tests the corresponding functionality. func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) @@ -102,6 +115,7 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) { } } +// TestConfigShowRun_NoActiveProfileReturnsStructuredError tests the corresponding functionality. func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) multi := &core.MultiAppConfig{ @@ -135,6 +149,7 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) { } } +// TestConfigInitCmd_LangFlag tests the corresponding functionality. func TestConfigInitCmd_LangFlag(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) @@ -156,6 +171,7 @@ func TestConfigInitCmd_LangFlag(t *testing.T) { } } +// TestConfigInitCmd_LangDefault tests the corresponding functionality. func TestConfigInitCmd_LangDefault(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) @@ -177,6 +193,7 @@ func TestConfigInitCmd_LangDefault(t *testing.T) { } } +// TestHasAnyNonInteractiveFlag tests the corresponding functionality. func TestHasAnyNonInteractiveFlag(t *testing.T) { tests := []struct { name string @@ -200,6 +217,7 @@ func TestHasAnyNonInteractiveFlag(t *testing.T) { } } +// TestConfigInitRun_NonTerminal_NoFlags_RejectsWithHint tests the corresponding functionality. func TestConfigInitRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) // TestFactory has IsTerminal=false by default @@ -217,6 +235,7 @@ func TestConfigInitRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { } } +// TestConfigRemoveCmd_FlagParsing tests the corresponding functionality. func TestConfigRemoveCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) @@ -237,6 +256,7 @@ func TestConfigRemoveCmd_FlagParsing(t *testing.T) { } } +// TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets tests the corresponding functionality. func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing.T) { configDir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) @@ -297,6 +317,7 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing } } +// TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID tests the corresponding functionality. func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) @@ -311,7 +332,7 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T }, } - err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en") + err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en", "") if err == nil { t.Fatal("expected conflict error") } @@ -320,6 +341,7 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T } } +// TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange tests the corresponding functionality. func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) { multi := &core.MultiAppConfig{ CurrentApp: "prod", @@ -335,7 +357,7 @@ func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) { }, } - err := updateExistingProfileWithoutSecret(multi, "", "app-new", core.BrandLark, "en") + err := updateExistingProfileWithoutSecret(multi, "", "app-new", core.BrandLark, "en", "") if err == nil { t.Fatal("expected error when changing app ID without a new secret") } @@ -347,10 +369,15 @@ func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) { // stubConfigExtProvider simulates env/sidecar credential mode for config guard tests. type stubConfigExtProvider struct{ name string } +// Name returns the provider's name. func (s *stubConfigExtProvider) Name() string { return s.name } + +// ResolveAccount resolves the external credential account. func (s *stubConfigExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) { return &extcred.Account{AppID: "test-app"}, nil } + +// ResolveToken resolves the external token. func (s *stubConfigExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) { return nil, nil } @@ -365,6 +392,7 @@ func newConfigFactoryWithExternalProvider(t *testing.T) *cmdutil.Factory { return f } +// TestConfigBlockedByExternalProvider tests the corresponding functionality. func TestConfigBlockedByExternalProvider(t *testing.T) { f := newConfigFactoryWithExternalProvider(t) diff --git a/cmd/config/init.go b/cmd/config/init.go index 3a3a7a84a..54d02a274 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -23,16 +23,17 @@ import ( // ConfigInitOptions holds all inputs for config init. type ConfigInitOptions struct { - Factory *cmdutil.Factory - Ctx context.Context - AppID string - appSecret string // internal only; populated from stdin, never from a CLI flag - AppSecretStdin bool // read app-secret from stdin (avoids process list exposure) - Brand string - New bool - Lang string - langExplicit bool // true when --lang was explicitly passed - ProfileName string // when set, create/update a named profile instead of replacing Apps[0] + Factory *cmdutil.Factory // Factory instance + Ctx context.Context // Command context + AppID string // AppID holds the application id + appSecret string // internal only; populated from stdin, never from a CLI flag + AppSecretStdin bool // AppSecretStdin read app-secret from stdin (avoids process list exposure) + Brand string // Brand is either feishu or lark + UserTokenGetterUrl string // UserTokenGetterUrl specifies custom fetch URL + New bool // New flag skips mode selection + Lang string // Lang selects interactive prompt language + langExplicit bool // langExplicit true when --lang was explicitly passed + ProfileName string // ProfileName when set, create/update a named profile instead of replacing Apps[0] } // NewCmdConfigInit creates the config init subcommand. @@ -61,6 +62,7 @@ verification URL from its output.`, cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)") cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure") cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)") + cmd.Flags().StringVar(&opts.UserTokenGetterUrl, "user-token-getter-url", "", "Custom url to fetch user token when app-secret is not provided") cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)") cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)") @@ -69,7 +71,7 @@ verification URL from its output.`, // hasAnyNonInteractiveFlag returns true if any non-interactive flag is set. func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool { - return o.New || o.AppID != "" || o.AppSecretStdin + return o.New || o.AppID != "" || o.AppSecretStdin || o.UserTokenGetterUrl != "" } // cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID. @@ -89,10 +91,10 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp } // saveAsOnlyApp overwrites config.json with a single-app config. -func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { +func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang, userTokenGetterUrl string) error { config := &core.MultiAppConfig{ Apps: []core.AppConfig{{ - AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{}, + AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, UserTokenGetterUrl: userTokenGetterUrl, Users: []core.AppUser{}, }}, } return core.SaveMultiAppConfig(config) @@ -101,18 +103,18 @@ func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, // saveInitConfig saves a new/updated app config, respecting --profile mode. // With profileName: appends or updates the named profile (preserves other profiles). // Without profileName: cleans up old config and saves as the only app. -func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { +func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang, userTokenGetterUrl string) error { if profileName != "" { - return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang) + return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang, userTokenGetterUrl) } cleanupOldConfig(existing, f, appId) - return saveAsOnlyApp(appId, secret, brand, lang) + return saveAsOnlyApp(appId, secret, brand, lang, userTokenGetterUrl) } // saveAsProfile appends or updates a named profile in the config. // If a profile with the same name exists, it updates it; otherwise appends. // When updating, cleans up old keychain secrets if AppId changed. -func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { +func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang, userTokenGetterUrl string) error { multi := existing if multi == nil { multi = &core.MultiAppConfig{} @@ -132,23 +134,27 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr multi.Apps[idx].AppSecret = secret multi.Apps[idx].Brand = brand multi.Apps[idx].Lang = lang + multi.Apps[idx].UserTokenGetterUrl = userTokenGetterUrl } else { if findAppIndexByAppID(multi, profileName) >= 0 { return fmt.Errorf("profile name %q conflicts with existing appId", profileName) } // Append new profile multi.Apps = append(multi.Apps, core.AppConfig{ - Name: profileName, - AppId: appId, - AppSecret: secret, - Brand: brand, - Lang: lang, - Users: []core.AppUser{}, + Name: profileName, + AppId: appId, + AppSecret: secret, + Brand: brand, + Lang: lang, + UserTokenGetterUrl: userTokenGetterUrl, + Users: []core.AppUser{}, }) } return core.SaveMultiAppConfig(multi) } +// findProfileIndexByName returns the index of the profile matching profileName. +// Returns -1 if not found. func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int { if multi == nil { return -1 @@ -161,6 +167,8 @@ func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int return -1 } +// findAppIndexByAppID returns the index of the app matching appID. +// Returns -1 if not found. func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int { if multi == nil { return -1 @@ -172,8 +180,9 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int { } return -1 } - -func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error { +// updateExistingProfileWithoutSecret updates an existing profile's properties +// without modifying its stored secret. Validates that the app ID wasn't changed. +func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string, userTokenGetterUrl string) error { if existing == nil { return output.ErrValidation("App Secret cannot be empty for new configuration") } @@ -199,9 +208,13 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa app.AppId = appID app.Brand = brand app.Lang = lang + if len(userTokenGetterUrl) > 0 { + app.UserTokenGetterUrl = userTokenGetterUrl + } return core.SaveMultiAppConfig(existing) } +// configInitRun is the main entry point for the config init command logic. func configInitRun(opts *ConfigInitOptions) error { f := opts.Factory @@ -233,17 +246,29 @@ func configInitRun(opts *ConfigInitOptions) error { } // Mode 1: Non-interactive - if opts.AppID != "" && opts.appSecret != "" { + if opts.AppID != "" && (opts.appSecret != "" || opts.UserTokenGetterUrl != "") { brand := parseBrand(opts.Brand) - secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain) - if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%v", err) + var secret core.SecretInput + if opts.appSecret != "" { + var err error + secret, err = core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%v", err) + } } - if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil { + if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang, opts.UserTokenGetterUrl); err != nil { return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) - output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand}) + + outputMap := map[string]interface{}{"appId": opts.AppID, "brand": brand} + if opts.appSecret != "" { + outputMap["appSecret"] = "****" + } + if opts.UserTokenGetterUrl != "" { + outputMap["userTokenGetterUrl"] = opts.UserTokenGetterUrl + } + output.PrintJson(f.IOStreams.Out, outputMap) return nil } @@ -281,7 +306,7 @@ func configInitRun(opts *ConfigInitOptions) error { if err != nil { return output.Errorf(output.ExitInternal, "internal", "%v", err) } - if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { + if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, opts.UserTokenGetterUrl); err != nil { return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) } output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand}) @@ -295,23 +320,23 @@ func configInitRun(opts *ConfigInitOptions) error { return err } if result == nil { - return output.ErrValidation("App ID and App Secret cannot be empty") + return output.ErrValidation("App ID cannot be empty, App Secret and UserTokenGetterUrl cannot be both empty") } existing, _ := core.LoadMultiAppConfig() - if result.AppSecret != "" { + if existing == nil { // New secret provided (either from "create" or "existing" with input) secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) if err != nil { return output.Errorf(output.ExitInternal, "internal", "%v", err) } - if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { + if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, result.UserTokenGetterUrl); err != nil { return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) } } else if result.Mode == "existing" && result.AppID != "" { // Existing app with unchanged secret — update app ID and brand only - if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil { + if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang, result.UserTokenGetterUrl); err != nil { var exitErr *output.ExitError if errors.As(err, &exitErr) { return err @@ -319,7 +344,7 @@ func configInitRun(opts *ConfigInitOptions) error { return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) } } else { - return output.ErrValidation("App ID and App Secret cannot be empty") + return output.ErrValidation("App ID cannot be empty") } if result.Mode == "existing" { @@ -381,6 +406,15 @@ func configInitRun(opts *ConfigInitOptions) error { return output.ErrValidation("%s", err) } + prompt = "UserTokenGetterUrl (Optional)" + if firstApp != nil && firstApp.UserTokenGetterUrl != "" { + prompt += fmt.Sprintf(" [%s]", firstApp.UserTokenGetterUrl) + } + getterUrlInput, err := readLine(prompt) + if err != nil { + return output.ErrValidation("%s", err) + } + resolvedAppId := appIdInput if resolvedAppId == "" && firstApp != nil { resolvedAppId = firstApp.AppId @@ -399,15 +433,23 @@ func configInitRun(opts *ConfigInitOptions) error { resolvedBrand = "feishu" } - if resolvedAppId == "" || resolvedSecret.IsZero() { - return output.ErrValidation("App ID and App Secret cannot be empty") + resolvedGetterUrl := getterUrlInput + if resolvedGetterUrl == "" && firstApp != nil { + resolvedGetterUrl = firstApp.UserTokenGetterUrl + } + + if resolvedAppId == "" { + return output.ErrValidation("App ID cannot be empty") + } + if resolvedSecret.IsZero() && resolvedGetterUrl == "" { + return output.ErrValidation("App Secret and UserTokenGetterUrl cannot be both empty") } storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain) if err != nil { return output.Errorf(output.ExitInternal, "internal", "%v", err) } - if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil { + if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang, resolvedGetterUrl); err != nil { return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) diff --git a/cmd/config/init_interactive.go b/cmd/config/init_interactive.go index 0a511cd0d..3712000b5 100644 --- a/cmd/config/init_interactive.go +++ b/cmd/config/init_interactive.go @@ -20,10 +20,11 @@ import ( // configInitResult holds the result of the interactive config init flow. type configInitResult struct { - Mode string // "create" or "existing" - Brand core.LarkBrand - AppID string - AppSecret string + Mode string // Mode "create" or "existing" + Brand core.LarkBrand // Brand identifier + AppID string // Application ID + AppSecret string // Application secret key + UserTokenGetterUrl string // URL to get user token } // runInteractiveConfigInit shows an interactive TUI for config init. @@ -65,7 +66,7 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er firstApp = existing.CurrentAppConfig("") } - var appID, appSecret, brand string + var appID, appSecret, userTokenGetterUrl, brand string appIDInput := huh.NewInput(). Title("App ID"). @@ -77,7 +78,7 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er } appSecretInput := huh.NewInput(). - Title("App Secret"). + Title("App Secret (App Secret or UserTokenGetterUrl, enter at least one)"). EchoMode(huh.EchoModePassword). Value(&appSecret) if firstApp != nil && !firstApp.AppSecret.IsZero() { @@ -86,6 +87,15 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er appSecretInput = appSecretInput.Placeholder("xxxx") } + userTokenGetterUrlInput := huh.NewInput(). + Title("UserTokenGetterUrl (Optional, the url must get token and send token data to http://127.0.0.1:${state})"). + Value(&userTokenGetterUrl) + if firstApp != nil && firstApp.UserTokenGetterUrl != "" { + userTokenGetterUrlInput = userTokenGetterUrlInput.Placeholder(firstApp.UserTokenGetterUrl) + } else { + userTokenGetterUrlInput = userTokenGetterUrlInput.Placeholder("http://...") + } + brand = "feishu" if firstApp != nil && firstApp.Brand != "" { brand = string(firstApp.Brand) @@ -95,6 +105,7 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er huh.NewGroup( appIDInput, appSecretInput, + userTokenGetterUrlInput, huh.NewSelect[string](). Title(msg.Platform). Options( @@ -116,8 +127,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er if appID == "" && firstApp != nil { appID = firstApp.AppId } - if appSecret == "" && firstApp != nil && !firstApp.AppSecret.IsZero() { - // Keep existing secret - caller will handle + if appSecret == "" && userTokenGetterUrl == "" && firstApp != nil && (!firstApp.AppSecret.IsZero() || firstApp.UserTokenGetterUrl != "") { + // Keep existing secret / url - caller will handle return &configInitResult{ Mode: "existing", Brand: parseBrand(brand), @@ -125,15 +136,19 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er }, nil } - if appID == "" || appSecret == "" { - return nil, output.ErrValidation("App ID and App Secret cannot be empty") + if appID == "" { + return nil, output.ErrValidation("App ID cannot be empty") + } + if appSecret == "" && userTokenGetterUrl == "" { + return nil, output.ErrValidation("App Secret and UserTokenGetterUrl cannot be both empty") } return &configInitResult{ - Mode: "existing", - Brand: parseBrand(brand), - AppID: appID, - AppSecret: appSecret, + Mode: "existing", + Brand: parseBrand(brand), + AppID: appID, + AppSecret: appSecret, + UserTokenGetterUrl: userTokenGetterUrl, }, nil } diff --git a/go.mod b/go.mod index 0a294e07f..a94f10fb7 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect diff --git a/go.sum b/go.sum index 14f1c667d..64c898076 100644 --- a/go.sum +++ b/go.sum @@ -41,8 +41,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= diff --git a/internal/core/config.go b/internal/core/config.go index ca27a3b39..36ce6ec54 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -20,10 +20,11 @@ import ( // Identity represents the caller identity for API requests. type Identity string +// The supported identities. const ( - AsUser Identity = "user" - AsBot Identity = "bot" - AsAuto Identity = "auto" + AsUser Identity = "user" // AsUser specifies the identity as a regular user. + AsBot Identity = "bot" // AsBot specifies the identity as an application bot. + AsAuto Identity = "auto" // AsAuto automatically determines identity based on available credentials. ) // IsBot returns true if the identity is bot. @@ -31,20 +32,21 @@ func (id Identity) IsBot() bool { return id == AsBot } // AppUser is a logged-in user record stored in config. type AppUser struct { - UserOpenId string `json:"userOpenId"` - UserName string `json:"userName"` + UserOpenId string `json:"userOpenId"` // UserOpenId is the user open id + UserName string `json:"userName"` // UserName is the display name } // AppConfig is a per-app configuration entry (stored format — secrets may be unresolved). type AppConfig struct { - Name string `json:"name,omitempty"` - AppId string `json:"appId"` - AppSecret SecretInput `json:"appSecret"` - Brand LarkBrand `json:"brand"` - Lang string `json:"lang,omitempty"` - DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto - StrictMode *StrictMode `json:"strictMode,omitempty"` - Users []AppUser `json:"users"` + Name string `json:"name,omitempty"` // Name of the app config + AppId string `json:"appId"` // AppId of the app + AppSecret SecretInput `json:"appSecret"` // AppSecret storage ref + Brand LarkBrand `json:"brand"` // Brand environment + Lang string `json:"lang,omitempty"` // Lang for messages + DefaultAs Identity `json:"defaultAs,omitempty"` // DefaultAs Identity (AsUser | AsBot | AsAuto) + UserTokenGetterUrl string `json:"user_token_getter_url,omitempty"` // UserTokenGetterUrl defines an endpoint for fetching user tokens without manual authorization flow. + StrictMode *StrictMode `json:"strictMode,omitempty"` // StrictMode configuration + Users []AppUser `json:"users"` // Users authenticated } // ProfileName returns the display name for this app config. @@ -58,10 +60,10 @@ func (a *AppConfig) ProfileName() string { // MultiAppConfig is the multi-app config file format. type MultiAppConfig struct { - StrictMode StrictMode `json:"strictMode,omitempty"` - CurrentApp string `json:"currentApp,omitempty"` - PreviousApp string `json:"previousApp,omitempty"` - Apps []AppConfig `json:"apps"` + StrictMode StrictMode `json:"strictMode,omitempty"` // StrictMode policy + CurrentApp string `json:"currentApp,omitempty"` // CurrentApp in use + PreviousApp string `json:"previousApp,omitempty"`// PreviousApp used + Apps []AppConfig `json:"apps"` // Apps configured } // CurrentAppConfig returns the currently active app config. @@ -152,14 +154,15 @@ func ValidateProfileName(name string) error { // CliConfig is the resolved single-app config used by downstream code. type CliConfig struct { - ProfileName string - AppID string - AppSecret string - Brand LarkBrand - DefaultAs Identity // AsUser | AsBot | AsAuto | "" (from config file) - UserOpenId string - UserName string - SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider + ProfileName string // ProfileName in use + AppID string // AppID for auth + AppSecret string // AppSecret resolved + Brand LarkBrand// Brand enum + DefaultAs Identity // DefaultAs (AsUser | AsBot | AsAuto) + UserTokenGetterUrl string // UserTokenGetterUrl to fetch user access token automatically if provided + UserOpenId string // UserOpenId of the authenticated user + UserName string // UserName of the authenticated user + SupportedIdentities uint8 `json:"-"` // SupportedIdentities bitflag: 1=user, 2=bot; set by credential provider } // identityBotBit is the bit flag for bot identity in SupportedIdentities. @@ -243,28 +246,33 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro } } - if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil { - return nil, &ConfigError{Code: 2, Type: "config", - Message: "appId and appSecret keychain key are out of sync", - Hint: err.Error()} - } + var secret string + var err error + if app.UserTokenGetterUrl == "" { + if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil { + return nil, &ConfigError{Code: 2, Type: "config", + Message: "appId and appSecret keychain key are out of sync", + Hint: err.Error()} + } - secret, err := ResolveSecretInput(app.AppSecret, kc) - if err != nil { - // If the error comes from the keychain, it will already be wrapped as an ExitError. - // For other errors (e.g. file read errors, unknown sources), wrap them as ConfigError. - var exitErr *output.ExitError - if errors.As(err, &exitErr) { - return nil, exitErr + secret, err = ResolveSecretInput(app.AppSecret, kc) + if err != nil { + // If the error comes from the keychain, it will already be wrapped as an ExitError. + // For other errors (e.g. file read errors, unknown sources), wrap them as ConfigError. + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return nil, exitErr + } + return nil, &ConfigError{Code: 2, Type: "config", Message: err.Error()} } - return nil, &ConfigError{Code: 2, Type: "config", Message: err.Error()} } cfg := &CliConfig{ - ProfileName: app.ProfileName(), - AppID: app.AppId, - AppSecret: secret, - Brand: app.Brand, - DefaultAs: app.DefaultAs, + ProfileName: app.ProfileName(), + AppID: app.AppId, + AppSecret: secret, + Brand: app.Brand, + DefaultAs: app.DefaultAs, + UserTokenGetterUrl: app.UserTokenGetterUrl, } if len(app.Users) > 0 { cfg.UserOpenId = app.Users[0].UserOpenId diff --git a/internal/credential/types.go b/internal/credential/types.go index 54620621d..ef686b77b 100644 --- a/internal/credential/types.go +++ b/internal/credential/types.go @@ -16,14 +16,15 @@ import ( // It intentionally mirrors only the resolved fields needed by runtime auth // and identity selection, without exposing core.CliConfig as a dependency. type Account struct { - ProfileName string - AppID string - AppSecret string - Brand core.LarkBrand - DefaultAs core.Identity - UserOpenId string - UserName string - SupportedIdentities uint8 + ProfileName string // ProfileName used + AppID string // AppID for auth + AppSecret string // AppSecret retrieved + Brand core.LarkBrand // Brand environment + DefaultAs core.Identity // DefaultAs selected + UserTokenGetterUrl string // UserTokenGetterUrl endpoint to dynamically get a user token + UserOpenId string // UserOpenId logged in + UserName string // UserName logged in + SupportedIdentities uint8 // SupportedIdentities bitmask } const runtimePlaceholderAppSecret = "__LARKSUITE_CLI_TOKEN_ONLY__" @@ -45,6 +46,7 @@ func RuntimeAppSecret(secret string) string { return runtimePlaceholderAppSecret } +// normalizeAccountAppSecret ensures empty secrets are converted to the placeholder. func normalizeAccountAppSecret(secret string) string { if HasRealAppSecret(secret) { return secret @@ -63,6 +65,7 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account { AppSecret: normalizeAccountAppSecret(cfg.AppSecret), Brand: cfg.Brand, DefaultAs: cfg.DefaultAs, + UserTokenGetterUrl: cfg.UserTokenGetterUrl, UserOpenId: cfg.UserOpenId, UserName: cfg.UserName, SupportedIdentities: cfg.SupportedIdentities, @@ -80,6 +83,7 @@ func (a *Account) ToCliConfig() *core.CliConfig { AppSecret: normalizeAccountAppSecret(a.AppSecret), Brand: a.Brand, DefaultAs: a.DefaultAs, + UserTokenGetterUrl: a.UserTokenGetterUrl, UserOpenId: a.UserOpenId, UserName: a.UserName, SupportedIdentities: a.SupportedIdentities, @@ -96,11 +100,13 @@ type AccountProvider interface { // Uses string constants matching extension/credential.TokenType for zero-cost conversion. type TokenType string +// The available token types. const ( - TokenTypeUAT TokenType = "uat" // User Access Token - TokenTypeTAT TokenType = "tat" // Tenant Access Token + TokenTypeUAT TokenType = "uat" // TokenTypeUAT represents a User Access Token. + TokenTypeTAT TokenType = "tat" // TokenTypeTAT represents a Tenant Access Token. ) +// String returns the string representation of a TokenType. func (t TokenType) String() string { return string(t) } // ParseTokenType converts a string to TokenType. @@ -117,28 +123,29 @@ func ParseTokenType(s string) (TokenType, bool) { // TokenSpec is the input to TokenProvider.ResolveToken. type TokenSpec struct { - Type TokenType - AppID string // identifies which app (multi-account); not sensitive + Type TokenType // Type of the token requested + AppID string // AppID identifies which app (multi-account); not sensitive } // TokenResult is the output of TokenProvider.ResolveToken. type TokenResult struct { - Token string - Scopes string // optional, space-separated; empty = skip scope pre-check + Token string // Token actual token string + Scopes string // Scopes optional, space-separated; empty = skip scope pre-check } // IdentityHint is credential-layer guidance for resolving the effective identity. type IdentityHint struct { - DefaultAs core.Identity - AutoAs core.Identity + DefaultAs core.Identity // DefaultAs specified by profile + AutoAs core.Identity // AutoAs fallback option } // TokenUnavailableError reports that no usable token was available. type TokenUnavailableError struct { - Source string - Type TokenType + Source string // Source provider name + Type TokenType // Type of token missing } +// Error returns the error message indicating no token is available. func (e *TokenUnavailableError) Error() string { if e.Source != "" { return fmt.Sprintf("no %s available from credential source %q", e.Type, e.Source) @@ -148,11 +155,12 @@ func (e *TokenUnavailableError) Error() string { // MalformedTokenResultError reports that a source returned an invalid token payload. type MalformedTokenResultError struct { - Source string - Type TokenType - Reason string + Source string // Source of the token + Type TokenType // Type of token generated + Reason string // Reason why it is malformed } +// Error returns the error message indicating malformed token results. func (e *MalformedTokenResultError) Error() string { return fmt.Sprintf("credential source %q returned malformed %s token: %s", e.Source, e.Type, e.Reason) } diff --git a/skills/lark-shared/SKILL.md b/skills/lark-shared/SKILL.md index 139b4c1e6..6fccfa52e 100644 --- a/skills/lark-shared/SKILL.md +++ b/skills/lark-shared/SKILL.md @@ -16,7 +16,7 @@ description: "飞书/Lark CLI 共享基础:应用配置初始化、认证登 ```bash # 发起配置(该命令会阻塞直到用户打开链接并完成操作或过期) -lark-cli config init --new +lark-cli config init ``` ## 认证