// Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package app import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net" "net/url" "os" "path/filepath" "runtime" "strings" "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) type authLoginConfig struct { Token string Force bool Device bool Recommend bool Yes bool TargetCorpID string HistoryProfileSelector string HistoryProfileSelectorExplicit bool International bool PreURL string MCPURL string } type authLoginEndpointOverrides struct { LoginURL string MCPURL string } type authLoginMCPPersistence uint8 const ( authLoginMCPUseDefault authLoginMCPPersistence = iota authLoginMCPUseManagedRegion authLoginMCPUseExplicitOverride ) type authLoginGuideAction string const ( authLoginGuideDirectCLI authLoginGuideAction = "direct_cli" authLoginGuideConfigureAgentApp authLoginGuideAction = "configure_agent_app" authLoginGuideManualCredentials authLoginGuideAction = "manual_credentials" ) var ( authLoginBrandBlue = lipgloss.AdaptiveColor{Light: "#1677FF", Dark: "#69B1FF"} authLoginInk = lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#EAF2FF"} authLoginMuted = lipgloss.AdaptiveColor{Light: "#667085", Dark: "#8A96A8"} authLoginLine = lipgloss.AdaptiveColor{Light: "#D6E4FF", Dark: "#2F3B52"} authLoginDanger = lipgloss.AdaptiveColor{Light: "#D92D20", Dark: "#FF6B6B"} ) func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command { cmd := &cobra.Command{ Use: "auth", Short: "认证管理", Long: "管理钉钉 CLI 的认证凭证。支持 OAuth 扫码登录和 Device Flow。", Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } if !edition.Get().HideAuthLogin { cmd.AddCommand(newAuthLoginCommand(patCaller)) } cmd.AddCommand( newAuthLogoutCommand(), newAuthStatusCommand(), newAuthMigrateKeychainCommand(), newAuthExportCommand(), newAuthImportCommand(), newAuthExchangeCommand(patCaller), newAuthResetCommand(), ) return cmd } func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "登录钉钉(自动刷新 token,必要时扫码)", Long: `登录钉钉并获取认证凭证。 支持的登录方式: - OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成 - OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境 - 自有应用 OAuth (--client-id/--client-secret): 使用指定应用完成用户授权 - 直接提供 Token (--token): 跳过授权,使用已有 token 不支持的登录方式: - 邮箱/密码登录 - 手机号/验证码登录 - 无用户授权的纯应用凭证 (client_credentials) 登录 区域: - 默认使用国内钉钉 .com 登录与服务端点 - --intl(或 --international)使用国际版 .io 登录;后续业务命令按所选 profile 自动路由 注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device, 否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。 示例: dws auth login # 本机登录并新增/刷新一个组织 profile dws auth login --profile # 指定本次授权目标组织,不持久切换当前组织 dws auth login --intl # 使用钉钉国际版 .io 登录入口 dws auth login --intl --pre-url https://pre-login.dingtalk.io dws auth login --intl --pre-url https://pre-mcp.dingtalk.io dws auth login --recommend # 无交互批量授权服务端推荐权限 dws auth login --device # SSH 远程 / 无头环境登录 (设备流) dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程 dws auth login --token xxx # 使用指定 token`, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := resolveAuthLoginConfig(cmd) if err != nil { return err } var preOverrides authLoginEndpointOverrides if cfg.PreURL != "" { var err error preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL) if err != nil { return err } restoreLoginBaseURL := authpkg.PushLoginBaseURLOverride(preOverrides.LoginURL) defer restoreLoginBaseURL() } mcpBaseURL, mcpPersistence, err := authLoginMCPBaseURLForConfig(cfg, preOverrides) if err != nil { return err } restoreMCPBaseURL := authpkg.PushMCPBaseURLOverride(mcpBaseURL) defer restoreMCPBaseURL() configDir := defaultConfigDir() var tokenData *authpkg.TokenData format, _ := cmd.Root().PersistentFlags().GetString("format") postLoginTUIMode := !cfg.Yes && authLoginShouldUsePostLoginTUIMode(cmd, format, cfg.Recommend) recommendAuthMode := cfg.Recommend || postLoginTUIMode humanAuthMode := !cfg.Yes && authLoginShouldUseHumanAuthorizationMode(cmd, format, recommendAuthMode) switch { case strings.TrimSpace(cfg.Token) != "": tokenData = &authpkg.TokenData{ AccessToken: cfg.Token, ExpiresAt: time.Now().Add(config.ManualTokenExpiry), } if cfg.International { tokenData.LoginRegion = string(authpkg.LoginRegionInternational) } if err := authSaveTokenData(configDir, tokenData); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err)) } case cfg.Device: loginCtx, cancel := context.WithTimeout(cmd.Context(), config.DeviceFlowTimeout) defer cancel() provider := authpkg.NewDeviceFlowProvider(configDir, nil) provider.Output = cmd.ErrOrStderr() provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser") if cfg.International { provider.SetLoginRegion(authpkg.LoginRegionInternational) } provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error { return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{ Selector: cfg.HistoryProfileSelector, Explicit: cfg.HistoryProfileSelectorExplicit, }) } tokenData, err = authDeviceLogin(provider, loginCtx) if err != nil { return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err)) } default: loginCtx, cancel := context.WithTimeout(cmd.Context(), config.OAuthFlowTimeout) defer cancel() provider := authpkg.NewOAuthProvider(configDir, nil) provider.Output = cmd.ErrOrStderr() provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser") provider.TargetCorpID = cfg.TargetCorpID if cfg.International { provider.LoginRegion = authpkg.LoginRegionInternational } provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error { return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{ Selector: cfg.HistoryProfileSelector, Explicit: cfg.HistoryProfileSelectorExplicit, }) } configureOAuthProviderCompatibility(provider, configDir) tokenData, err = authOAuthLogin(provider, loginCtx, authLoginForcesAuthorization(cfg)) if err != nil { return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err)) } } if tokenData != nil { if err := persistAuthLoginMCPBaseURL(configDir, mcpBaseURL, mcpPersistence); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to persist MCP URL: %v", err)) } } ResetRuntimeTokenCache() clearCompatCache() w := cmd.OutOrStdout() postLoginSelector := authpkg.TokenProfileSelector(tokenData) if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" { if profiles, loadErr := authLoadProfiles(configDir); loadErr == nil && profiles != nil { for i := range profiles.Profiles { profile := profiles.Profiles[i] if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(tokenData.CorpID) && strings.TrimSpace(profile.UserID) == "" { postLoginSelector = profileCLISelector(profile, profiles) break } } } } runPostLoginAuthorization := func() error { if !recommendAuthMode { return nil } restoreProfile := replaceRuntimeProfile(postLoginSelector) defer restoreProfile() recommendScopeMode := pat.LoginRecommendScopeRecommended var initialPlan *pat.LoginRecommendPlan if postLoginTUIMode { var planErr error initialPlan, planErr = authPlanLoginRecommend(cmd.Context(), patCaller) if planErr != nil { return planErr } if authLoginRecommendPlanSkipsInteractiveAuthorization(initialPlan) { fmt.Fprintln(cmd.ErrOrStderr(), "推荐权限已全部授权或没有可授权项") return nil } var err error recommendScopeMode, err = loginRecommendScopeModeSelector() if err != nil { return err } } opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes, ScopeMode: recommendScopeMode, InitialPlan: initialPlan} if postLoginTUIMode { opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { return loginRecommendProductSelector(products) } } retryFormat := format if humanAuthMode { retryFormat = "table" } run := func(ctx context.Context) error { return authRunLoginRecommend(ctx, patCaller, cmd.ErrOrStderr(), opts) } err := run(cmd.Context()) if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil { return authRunDirectPATWait( cmd.Context(), &GlobalFlags{Format: retryFormat}, patErr, cmd.ErrOrStderr(), ) } return err } // Check if JSON output is requested if strings.EqualFold(strings.TrimSpace(format), "json") && !humanAuthMode { if err := runPostLoginAuthorization(); err != nil { return err } return writeAuthLoginJSON(w, tokenData, authLoginForcesAuthorization(cfg)) } // Default table output if err := runPostLoginAuthorization(); err != nil { return err } fmt.Fprintln(w) fmt.Fprintln(w, authLoginStatusLine("登录成功!")) if tokenData != nil { if tokenData.CorpName != "" { fmt.Fprintln(w, authLoginInfoLine("企业", tokenData.CorpName)) } if tokenData.CorpID != "" { fmt.Fprintln(w, authLoginInfoLine("企业 ID", tokenData.CorpID)) } if tokenData.UserName != "" { fmt.Fprintln(w, authLoginInfoLine("用户", tokenData.UserName)) } if expiry := authLoginDisplayExpiry(tokenData); expiry != "" { fmt.Fprintln(w, authLoginInfoLine("有效期", expiry)) } } fmt.Fprintln(w, authLoginMutedStyle().Render("Token 将自动刷新,无需重复登录")) return nil }, } cmd.Flags().String("token", "", "Access token") cmd.Flags().Bool("device", false, "Use device authorization flow") cmd.Flags().Bool("intl", false, "Use DingTalk international (.io) login and service endpoints") cmd.Flags().Bool("international", false, "Use DingTalk international (.io) login and service endpoints") cmd.Flags().String("pre-url", "", "Override pre-release login/MCP base URL for this login") cmd.Flags().String("mcp-url", "", "Override MCP base URL for this login") cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程") cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限") // Hidden compatibility flags cmd.Flags().String("redirect-url", "", "Loopback redirect URL") cmd.Flags().String("scopes", "", "Space-separated DingTalk OAuth scopes") cmd.Flags().String("authorize-url", "", "Override DingTalk authorization URL") cmd.Flags().String("token-url", "", "Override DingTalk token exchange URL") cmd.Flags().String("refresh-url", "", "Override DingTalk refresh token URL") cmd.Flags().Int("login-timeout", 0, "Login timeout seconds") cmd.Flags().Bool("no-browser", false, "Suppress browser launch") _ = cmd.Flags().MarkHidden("redirect-url") _ = cmd.Flags().MarkHidden("scopes") _ = cmd.Flags().MarkHidden("authorize-url") _ = cmd.Flags().MarkHidden("token-url") _ = cmd.Flags().MarkHidden("refresh-url") _ = cmd.Flags().MarkHidden("login-timeout") return cmd } var ( authLoginGuideActionSelector = selectAuthLoginGuideAction authLoginGuideActionApplier = applyAuthLoginGuideAction authLoginManualCredentialsPrompt = promptAuthLoginManualCredentials loginRecommendScopeModeSelector = selectLoginRecommendScopeMode loginRecommendProductSelector = selectLoginRecommendProducts authLoginInteractiveTerminal = isInteractiveTerminal migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK authMigrateTarget = func(cmd *cobra.Command) (string, error) { return cmd.Flags().GetString("to") } authRunForm = (*huh.Form).Run authSaveTokenData = authpkg.SaveLoginTokenData authSaveAppConfig = authpkg.SaveAppConfig authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) { return provider.Login(ctx) } authOAuthLogin = func(provider *authpkg.OAuthProvider, ctx context.Context, force bool) (*authpkg.TokenData, error) { return provider.Login(ctx, force) } authOAuthStatus = func(provider *authpkg.OAuthProvider) (*authpkg.TokenData, error) { return provider.Status() } authOAuthAccessToken = func(provider *authpkg.OAuthProvider, ctx context.Context) (string, error) { return provider.GetAccessToken(ctx) } authOAuthExchange = func(provider *authpkg.OAuthProvider, ctx context.Context, code, uid string) (*authpkg.TokenData, error) { return provider.ExchangeAuthCode(ctx, code, uid) } authPlanLoginRecommend = pat.PlanLoginRecommendAuthorization authRunLoginRecommend = pat.RunLoginRecommendAuthorizationWithOptions authRunDirectPATWait = runDirectPATAuthCheckWaitOnly authResolveProfile = authpkg.ResolveProfile authResolveProfileDeletion = authpkg.ResolveProfileDeletionScope authRevokeToken = authpkg.RevokeTokenRemote authRevokeTokenForData = authpkg.RevokeTokenRemoteForData authLoadTokenForProfile = authpkg.LoadTokenDataForProfile authDeleteProfileToken = authpkg.DeleteTokenDataForProfile authEnsureProfilesMigration = authpkg.EnsureProfilesMigration authLoadProfiles = authpkg.LoadProfiles authDeleteAllTokenData = authpkg.DeleteAllTokenData authDeleteTokenData = authpkg.DeleteTokenData authMarkProfileStatus = authpkg.MarkProfileStatus authPortableExportSupported = authpkg.PortableExportSupported authPortableSourceReady = authpkg.PortableAuthSourceReady authPortableTargetPopulated = authpkg.PortableAuthTargetPopulated authExportPortableBundle = authpkg.ExportPortableAuthBundle authImportPortableBundle = authpkg.ImportPortableAuthBundle authAtomicWrite = helpers.AtomicWrite authReadFile = os.ReadFile authRemove = os.Remove authDeleteAppConfig = authpkg.DeleteAppConfig ) func selectAuthLoginGuideAction() (authLoginGuideAction, error) { choice := authLoginGuideDirectCLI form := huh.NewForm( huh.NewGroup( huh.NewSelect[authLoginGuideAction](). Title("选择操作"). Options( huh.NewOption("直接使用CLI", authLoginGuideDirectCLI), huh.NewOption("一键配置智能体应用", authLoginGuideConfigureAgentApp), huh.NewOption("手动输入应用凭证", authLoginGuideManualCredentials), ). Value(&choice), ), ).WithTheme(authLoginHuhTheme()) if err := authRunForm(form); err != nil { return "", fmt.Errorf("使用引导选择中止: %w", err) } return choice, nil } func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action authLoginGuideAction) error { switch action { case authLoginGuideDirectCLI: return nil case authLoginGuideConfigureAgentApp: fmt.Fprintln(cmd.ErrOrStderr(), "一键配置智能体应用暂未开放,已继续使用 CLI 登录") return nil case authLoginGuideManualCredentials: clientID, clientSecret, err := authLoginManualCredentialsPrompt() if err != nil { return err } authpkg.SetClientID(clientID) authpkg.SetClientSecret(clientSecret) if err := authSaveAppConfig(configDir, &authpkg.AppConfig{ ClientID: clientID, ClientSecret: authpkg.PlainSecret(clientSecret), }); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to persist app credentials: %v", err)) } return nil default: return fmt.Errorf("未知操作: %s", action) } } func promptAuthLoginManualCredentials() (string, string, error) { var clientID, clientSecret string form := huh.NewForm( huh.NewGroup( huh.NewInput(). Title("输入 AppKey"). Value(&clientID). Validate(authLoginNonEmpty("AppKey")), huh.NewInput(). Title("输入 AppSecret"). EchoMode(huh.EchoModePassword). Value(&clientSecret). Validate(authLoginNonEmpty("AppSecret")), ), ).WithTheme(authLoginHuhTheme()) if err := authRunForm(form); err != nil { return "", "", fmt.Errorf("应用凭证输入中止: %w", err) } return strings.TrimSpace(clientID), strings.TrimSpace(clientSecret), nil } func authLoginNonEmpty(label string) func(string) error { return func(value string) error { if strings.TrimSpace(value) == "" { return fmt.Errorf("%s 不能为空", label) } return nil } } func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) { choice := pat.LoginRecommendScopeRecommended form := huh.NewForm( huh.NewGroup( huh.NewSelect[pat.LoginRecommendScopeMode](). Title("选择授权范围"). Description("空格选择 回车确认"). Options( huh.NewOption("推荐授权", pat.LoginRecommendScopeRecommended), huh.NewOption("全部授权", pat.LoginRecommendScopeAll), ). Value(&choice), ), ).WithTheme(authLoginHuhTheme()) if err := authRunForm(form); err != nil { return "", fmt.Errorf("授权范围选择中止: %w", err) } return choice, nil } func newAuthLogoutCommand() *cobra.Command { cmd := &cobra.Command{ Use: "logout", Short: "清除认证信息(默认退出所有组织)", Long: `清除本机钉钉登录态。 默认退出全部账号。--profile 传组织时退出该组织全部账号;传精确账号或本地 profile 名时只退出一个账号。`, Example: ` dws auth logout dws auth logout --profile dws auth logout --profile : dws auth logout --profile "钉钉"`, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { configDir := defaultConfigDir() profileSelector, err := cmd.Flags().GetString("profile") if err != nil { return apperrors.NewInternal("failed to read --profile") } revokeCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second) defer cancel() if strings.TrimSpace(profileSelector) != "" { if err := logoutOneProfile(cmd, revokeCtx, configDir, profileSelector); err != nil { return err } } else { if err := logoutAllProfiles(cmd, revokeCtx, configDir); err != nil { return err } } ResetRuntimeTokenCache() clearCompatCache() w := cmd.OutOrStdout() fmt.Fprintln(w, "[OK] 已清除认证信息") if !edition.Get().IsEmbedded { fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录") } return nil }, } cmd.Flags().String("profile", "", "指定组织或账号:corpId、corpName、corpId:userId、corpId:userName、corpName:userId、corpName:userName 或本地 profile 名") return cmd } func newAuthStatusCommand() *cobra.Command { cmd := &cobra.Command{ Use: "status", Short: "查看认证状态", Long: `查看当前或指定组织 profile 的认证状态。 指定 --profile 时只读取并刷新被选中的 token slot,不会修改 currentProfile。`, Example: ` dws auth status dws auth status --profile dws auth status --profile : dws auth status --profile "钉钉:孙博文" dws auth status --profile --format json`, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { configDir := defaultConfigDir() profileSelector, err := cmd.Flags().GetString("profile") if err != nil { return apperrors.NewInternal("failed to read --profile") } profileSelector = strings.TrimSpace(profileSelector) if profileSelector != "" { selected, resolveErr := authpkg.ResolveProfile(configDir, profileSelector) if resolveErr != nil { return apperrors.NewValidation(resolveErr.Error()) } if selected == nil { return apperrors.NewValidation(fmt.Sprintf("profile %q not found", profileSelector)) } if strings.TrimSpace(selected.UserID) != "" { profileSelector = authpkg.ProfileSelector(*selected) } } restoreProfile := pushRuntimeProfile(profileSelector) defer restoreProfile() authenticated := false refreshed := false var tokenData *authpkg.TokenData var statusErr error var refreshFailure error provider := authpkg.NewOAuthProvider(configDir, nil) configureOAuthProviderCompatibility(provider, configDir) if data, err := authOAuthStatus(provider); err == nil { tokenData = data if !data.IsAccessTokenValid() && data.IsRefreshTokenValid() { refreshCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second) _, refreshErr := authOAuthAccessToken(provider, refreshCtx) cancel() if refreshErr == nil { if updatedData, statusErr := authOAuthStatus(provider); statusErr == nil { tokenData = updatedData refreshed = true } } else if edition.Get().AutoPurgeToken { refreshFailure = refreshErr _ = authDeleteTokenData(configDir) } else if tokenData != nil { refreshFailure = refreshErr markSelector := profileSelector if markSelector == "" { markSelector = authpkg.StableTokenProfileSelector(configDir, tokenData) } _ = authMarkProfileStatus(configDir, markSelector, authpkg.ProfileStatusExpired) } } if refreshFailure == nil && authStatusAuthenticated(tokenData) { authenticated = true } } else { statusErr = err } diagnostic := authStatusDiagnosticFromError(statusErr) if refreshFailure != nil { diagnostic = authStatusRefreshDiagnostic(refreshFailure) } // Check if JSON output is requested format, _ := cmd.Root().PersistentFlags().GetString("format") if strings.EqualFold(strings.TrimSpace(format), "json") { return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData, diagnostic) } // Default table output w := cmd.OutOrStdout() if authenticated { if refreshed { fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅") fmt.Fprintln(w, "Token 已自动刷新") } else { fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅") } if tokenData != nil { if tokenData.CorpName != "" { fmt.Fprintf(w, "%-16s%s\n", "企业:", tokenData.CorpName) } if tokenData.CorpID != "" { fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID) } if tokenData.IsRefreshTokenValid() { fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "有效 ✅") } else { fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "缺失或已过期 ⚠️") } } if updatedAt := authStatusUpdatedAt(tokenData); updatedAt != "" { fmt.Fprintf(w, "%-16s%s\n", "有效期:", updatedAt) } } else { fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录") if diagnostic != nil { fmt.Fprintf(w, "%-16s%s\n", "原因:", diagnostic.Message) fmt.Fprintf(w, "%-16s%s\n", "提示:", diagnostic.Hint) } else if !edition.Get().IsEmbedded { fmt.Fprintln(w, "运行 dws auth login --recommend 进行登录") } } return nil }, } cmd.Flags().String("profile", "", "指定组织或账号:corpId、corpName、corpId:userId、corpId:userName、corpName:userId、corpName:userName 或本地 profile 名") return cmd } func newAuthMigrateKeychainCommand() *cobra.Command { cmd := &cobra.Command{ Use: "migrate-keychain", Short: "将 macOS 系统 Keychain 登录态安全迁移到 file-DEK", Long: `将 dws-cli 的 legacy 与 profile 登录 token 统一重加密为 file-DEK,使 Codex 等沙箱进程与普通终端共享同一登录态。 迁移必须从仍可读取原登录态的系统 Keychain 模式运行。命令会先验证全部认证密文;任何认证条目不可解密时均不会写入。应用密钥等无关条目不在迁移范围内。 先用 --dry-run 预检,确认后加 --yes 执行。`, Example: ` env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json`, Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { target, err := authMigrateTarget(cmd) if err != nil { return apperrors.NewInternal("failed to read --to") } if strings.TrimSpace(target) != "file-dek" { return apperrors.NewValidation("--to 当前仅支持 file-dek") } if os.Getenv(keychain.DisableKeychainEnv) != "" { return apperrors.NewValidation(fmt.Sprintf( "迁移必须从系统 Keychain 模式运行;请使用 `env -u %s dws auth migrate-keychain --to file-dek ...`", keychain.DisableKeychainEnv, )) } dryRun, _ := cmd.Root().PersistentFlags().GetBool("dry-run") yes, _ := cmd.Root().PersistentFlags().GetBool("yes") if !dryRun && !yes { return apperrors.NewValidation("迁移会重加密全部本地登录 token;请先使用 --dry-run 预检,确认后加 --yes 执行") } count, err := migrateKeychainToFileDEK(defaultConfigDir(), dryRun) if err != nil { return apperrors.NewInternal(fmt.Sprintf("keychain migration failed: %v", err)) } result := struct { Success bool `json:"success"` DryRun bool `json:"dry_run"` Target string `json:"target"` Entries int `json:"entries"` }{Success: true, DryRun: dryRun, Target: "file-dek", Entries: count} format, _ := cmd.Root().PersistentFlags().GetString("format") if strings.EqualFold(strings.TrimSpace(format), "json") { return json.NewEncoder(cmd.OutOrStdout()).Encode(result) } if dryRun { fmt.Fprintf(cmd.OutOrStdout(), "预检通过:%d 个本地认证条目可迁移到 file-DEK\n", count) } else { fmt.Fprintf(cmd.OutOrStdout(), "迁移完成:%d 个本地认证条目已统一使用 file-DEK\n", count) } return nil }, } cmd.Flags().String("to", "file-dek", "目标密钥后端(当前仅支持 file-dek)") return cmd } func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error { selected, exact, err := authResolveProfileDeletion(configDir, selector) if err != nil { return apperrors.NewValidation(err.Error()) } if selected == nil { return apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector)) } stableSelector := selected.CorpID if exact { if strings.TrimSpace(selected.UserID) == "" { // A blank historical profile can coexist with exact accounts in the // same organization. Preserve the exact local-name selector; reducing // it to corpId would log out the entire organization. stableSelector = strings.TrimSpace(selector) } else { stableSelector = authpkg.ProfileSelector(*selected) } if data, loadErr := authLoadTokenForProfile(configDir, stableSelector); loadErr == nil { _ = authRevokeTokenForData(ctx, data) } } else if cfg, loadErr := authLoadProfiles(configDir); loadErr == nil { for _, profile := range cfg.Profiles { if profile.CorpID != selected.CorpID { continue } if data, tokenErr := authLoadTokenForProfile(configDir, profileCLISelector(profile, cfg)); tokenErr == nil { _ = authRevokeTokenForData(ctx, data) } } } if err := authDeleteProfileToken(configDir, stableSelector); err != nil { if strings.Contains(err.Error(), "not found") { return apperrors.NewValidation(err.Error()) } return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err)) } return nil } func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string) error { if err := authEnsureProfilesMigration(configDir); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err)) } cfg, err := authLoadProfiles(configDir) if err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err)) } if cfg == nil || len(cfg.Profiles) == 0 { _ = authRevokeToken(ctx) } else { for _, profile := range cfg.Profiles { if data, tokenErr := authLoadTokenForProfile(configDir, profileCLISelector(profile, cfg)); tokenErr == nil { _ = authRevokeTokenForData(ctx, data) } } } if err := authDeleteAllTokenData(configDir); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err)) } return nil } func pushRuntimeProfile(selector string) func() { selector = strings.TrimSpace(selector) if selector == "" { return func() {} } previous := authpkg.RuntimeProfile() authpkg.SetRuntimeProfile(selector) return func() { authpkg.SetRuntimeProfile(previous) } } func replaceRuntimeProfile(selector string) func() { previous := authpkg.RuntimeProfile() authpkg.SetRuntimeProfile(strings.TrimSpace(selector)) return func() { authpkg.SetRuntimeProfile(previous) } } func newAuthExportCommand() *cobra.Command { return newAuthExportCommandWithSupport(authpkg.PortableExportSupportError) } func newAuthExportCommandWithSupport(supportError func() error) *cobra.Command { cmd := &cobra.Command{ Use: "export", Short: "导出可迁移认证包", Long: `导出包含 refresh token 与解密材料的认证包,便于在另一台 Linux 沙箱中导入。 包内包含 ~/.local/share/dws-cli 加密 keychain 与 ~/.dws 必要配置,不含 token 明文。 示例: dws auth export -o dws-auth.tar.gz dws auth export --base64 > dws-auth.b64`, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { output, err := cmd.Flags().GetString("output") if err != nil { return apperrors.NewInternal("failed to read --output") } asBase64, err := cmd.Flags().GetBool("base64") if err != nil { return apperrors.NewInternal("failed to read --base64") } output = strings.TrimSpace(output) if !asBase64 && output == "" { return apperrors.NewValidation("--output is required unless --base64 is used") } if err := supportError(); err != nil { return apperrors.NewValidation(err.Error()) } if !authPortableExportSupported() { return apperrors.NewValidation(fmt.Sprintf( "macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录", keychain.DisableKeychainEnv, )) } if !authPortableSourceReady() { return apperrors.NewValidation("尚未登录,请先运行 dws auth login --recommend") } var bundle bytes.Buffer if err := authExportPortableBundle(defaultConfigDir(), &bundle); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to export auth bundle: %v", err)) } if asBase64 { payload := []byte(base64.StdEncoding.EncodeToString(bundle.Bytes()) + "\n") if output == "" { _, err := cmd.OutOrStdout().Write(payload) return err } if err := authAtomicWrite(output, payload, config.FilePerm); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err)) } fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output) fmt.Fprintf(cmd.ErrOrStderr(), "认证包含敏感凭据,用完请删除: rm -P %s\n", output) return nil } if err := authAtomicWrite(output, bundle.Bytes(), config.FilePerm); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err)) } fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output) fmt.Fprintf(cmd.ErrOrStderr(), "认证包含敏感凭据,用完请删除: rm -P %s\n", output) return nil }, } cmd.Flags().StringP("output", "o", "", "认证包输出路径") cmd.Flags().Bool("base64", false, "将认证包编码为 base64,便于复制粘贴") return cmd } func newAuthImportCommand() *cobra.Command { return newAuthImportCommandWithSupport(authpkg.PortableImportSupportError) } func newAuthImportCommandWithSupport(supportError func() error) *cobra.Command { cmd := &cobra.Command{ Use: "import", Short: "导入可迁移认证包", Long: `从 dws auth export 生成的 tar.gz 或 base64 文件恢复认证。 导入后请运行 dws auth status 确认 refresh token 仍有效。 示例: dws auth import -i dws-auth.tar.gz dws auth import -i dws-auth.b64 --base64`, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { input, err := cmd.Flags().GetString("input") if err != nil { return apperrors.NewInternal("failed to read --input") } input = strings.TrimSpace(input) if input == "" { return apperrors.NewValidation("--input is required") } asBase64, err := cmd.Flags().GetBool("base64") if err != nil { return apperrors.NewInternal("failed to read --base64") } force, err := cmd.Flags().GetBool("force") if err != nil { return apperrors.NewInternal("failed to read --force") } if err := supportError(); err != nil { return apperrors.NewValidation(err.Error()) } configDir := defaultConfigDir() if !force && authPortableTargetPopulated(configDir) { return apperrors.NewValidation("检测到已有登录态,请使用 --force 确认覆盖") } payload, err := authReadFile(input) if err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to read auth bundle: %v", err)) } if asBase64 { payload, err = base64.StdEncoding.DecodeString(strings.TrimSpace(string(payload))) if err != nil { return apperrors.NewValidation(fmt.Sprintf("invalid base64 auth bundle: %v", err)) } } report, err := authImportPortableBundle(configDir, bytes.NewReader(payload)) if err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to import auth bundle: %v", err)) } if report.OSMismatch { fmt.Fprintf(cmd.ErrOrStderr(), "警告: 认证包来自 %s,当前系统为 %s,请确认解密材料兼容\n", report.BundleOS, runtime.GOOS) } ResetRuntimeTokenCache() clearCompatCache() fmt.Fprintln(cmd.OutOrStdout(), "[OK] 已导入认证包") fmt.Fprintln(cmd.OutOrStdout(), "请运行 dws auth status 验证登录状态") return nil }, } cmd.Flags().StringP("input", "i", "", "认证包输入路径") cmd.Flags().Bool("base64", false, "输入为 base64 编码的认证包") cmd.Flags().Bool("force", false, "覆盖已有登录态") return cmd } func newAuthExchangeCommand(caller edition.ToolCaller) *cobra.Command { cmd := &cobra.Command{ Use: "exchange", Short: "Exchange an authorization code for credentials", Hidden: true, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { code, err := cmd.Flags().GetString("code") if err != nil { return apperrors.NewInternal("failed to read --code") } code = strings.TrimSpace(code) if code == "" { return apperrors.NewValidation("--code is required") } uid, err := cmd.Flags().GetString("uid") if err != nil { return apperrors.NewInternal("failed to read --uid") } configDir := defaultConfigDir() provider := authpkg.NewOAuthProvider(configDir, nil) provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error { return enrichAuthLoginProfileFromContact(ctx, configDir, caller, data) } configureOAuthProviderCompatibility(provider, configDir) exchangeCtx, cancel := context.WithTimeout(cmd.Context(), time.Minute) defer cancel() tokenData, err := authOAuthExchange(provider, exchangeCtx, code, strings.TrimSpace(uid)) if err != nil { return apperrors.NewAuth(fmt.Sprintf("failed to exchange authorization code: %v", err)) } ResetRuntimeTokenCache() clearCompatCache() w := cmd.OutOrStdout() fmt.Fprintln(w, "[OK] 授权码兑换成功!") if strings.TrimSpace(uid) != "" { fmt.Fprintf(w, "%-16s%s\n", "用户:", strings.TrimSpace(uid)) } if strings.TrimSpace(tokenData.CorpID) != "" { fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID) } if !tokenData.ExpiresAt.IsZero() { fmt.Fprintf(w, "%-16s%s\n", "有效期:", authLoginFormatExpiry(tokenData.ExpiresAt)) } return nil }, } cmd.Flags().String("code", "", "Authorization code") cmd.Flags().String("uid", "", "Optional user identifier for compatibility") cmd.Flags().String("client-id", "", "Compatibility flag") cmd.Flags().String("authorize-url", "", "Compatibility flag") cmd.Flags().String("token-url", "", "Compatibility flag") cmd.Flags().String("refresh-url", "", "Compatibility flag") cmd.Flags().String("redirect-url", "", "Compatibility flag") cmd.Flags().String("scopes", "", "Compatibility flag") return cmd } func newAuthResetCommand() *cobra.Command { return &cobra.Command{ Use: "reset", Short: "重置认证信息(清除本地 Token,触发重新授权)", DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { configDir := defaultConfigDir() if err := authDeleteAllTokenData(configDir); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err)) } _ = authRemove(filepath.Join(configDir, "mcp_url")) _ = authRemove(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)) _ = authRemove(filepath.Join(configDir, "token")) _ = authDeleteAppConfig(configDir) ResetRuntimeTokenCache() clearCompatCache() w := cmd.OutOrStdout() fmt.Fprintln(w, "[OK] 认证信息已重置") if !edition.Get().IsEmbedded { fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录") } return nil }, } } func timeOrEmpty(t time.Time) string { if t.IsZero() { return "" } return t.UTC().Format(time.RFC3339) } func authLoginFormatExpiry(t time.Time) string { remaining := time.Until(t) if remaining <= 0 { return "已过期" } if remaining > 24*time.Hour { return fmt.Sprintf("%.0f 天后", remaining.Hours()/24) } return fmt.Sprintf("%.0f 小时后", remaining.Hours()) } // authLoginDisplayExpiry 返回用于显示的有效期(优先显示 refresh token 有效期) func authLoginDisplayExpiry(data *authpkg.TokenData) string { if data == nil { return "" } // 优先使用 refresh token 有效期(更长,对用户更有意义) if data.IsRefreshTokenValid() { return authLoginFormatExpiry(data.RefreshExpAt) } // 回退到 access token 有效期 if !data.ExpiresAt.IsZero() { return authLoginFormatExpiry(data.ExpiresAt) } return "" } func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]string, error) { if len(products) == 0 { return nil, nil } selected := make([]string, 0, len(products)) options := make([]huh.Option[string], 0, len(products)) for _, product := range products { code := strings.TrimSpace(product.ProductCode) if code == "" { continue } selected = append(selected, code) options = append(options, huh.NewOption(loginRecommendProductLabel(product), code).Selected(true)) } if len(options) == 0 { return nil, nil } height := len(options) if height > 15 { height = 15 } form := huh.NewForm( huh.NewGroup( huh.NewMultiSelect[string](). Title("选择要授权的业务域"). Description("空格选择 回车确认"). Options(options...). Height(height). Value(&selected). Validate(authLoginProductsNonEmpty), ), ).WithTheme(authLoginHuhTheme()) if err := authRunForm(form); err != nil { return nil, fmt.Errorf("授权业务域选择中止: %w", err) } return selected, nil } func authLoginProductsNonEmpty(values []string) error { if len(values) == 0 { return fmt.Errorf("至少选择一个授权业务域") } return nil } func authLoginHuhTheme() *huh.Theme { t := huh.ThemeBase() t.Form.Base = lipgloss.NewStyle().Foreground(authLoginInk) t.FieldSeparator = lipgloss.NewStyle().SetString("\n") t.Focused.Base = t.Focused.Base.BorderForeground(authLoginBrandBlue) t.Focused.Card = t.Focused.Base t.Focused.Title = lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true) t.Focused.NoteTitle = t.Focused.Title.MarginBottom(1) t.Focused.Description = authLoginMutedStyle() t.Focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *").Foreground(authLoginDanger) t.Focused.ErrorMessage = lipgloss.NewStyle().SetString(" *").Foreground(authLoginDanger) t.Focused.SelectSelector = lipgloss.NewStyle().SetString("› ").Foreground(authLoginBrandBlue).Bold(true) t.Focused.MultiSelectSelector = t.Focused.SelectSelector t.Focused.Option = lipgloss.NewStyle().Foreground(authLoginInk) t.Focused.SelectedOption = lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true) t.Focused.SelectedPrefix = lipgloss.NewStyle().SetString("● ").Foreground(authLoginBrandBlue) t.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(authLoginInk) t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString("○ ").Foreground(authLoginMuted) t.Focused.NextIndicator = lipgloss.NewStyle().SetString("→").Foreground(authLoginBrandBlue) t.Focused.PrevIndicator = lipgloss.NewStyle().SetString("←").Foreground(authLoginMuted) t.Focused.FocusedButton = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#0B1220"}). Background(authLoginBrandBlue). Padding(0, 2). Bold(true) t.Focused.BlurredButton = lipgloss.NewStyle(). Foreground(authLoginInk). Background(authLoginLine). Padding(0, 2) t.Focused.Next = t.Focused.FocusedButton t.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(authLoginBrandBlue) t.Focused.TextInput.CursorText = lipgloss.NewStyle().Foreground(authLoginInk) t.Focused.TextInput.Placeholder = authLoginMutedStyle() t.Focused.TextInput.Prompt = lipgloss.NewStyle().Foreground(authLoginBrandBlue) t.Focused.TextInput.Text = lipgloss.NewStyle().Foreground(authLoginInk) t.Blurred = t.Focused t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()).BorderForeground(authLoginLine) t.Blurred.Card = t.Blurred.Base t.Blurred.Title = lipgloss.NewStyle().Foreground(authLoginInk) t.Blurred.NoteTitle = t.Blurred.Title.MarginBottom(1) t.Blurred.Description = authLoginMutedStyle() t.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ") t.Blurred.MultiSelectSelector = t.Blurred.SelectSelector t.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(authLoginInk) t.Blurred.SelectedPrefix = lipgloss.NewStyle().SetString("● ").Foreground(authLoginBrandBlue) t.Blurred.UnselectedOption = lipgloss.NewStyle().Foreground(authLoginMuted) t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString("○ ").Foreground(authLoginMuted) t.Blurred.NextIndicator = lipgloss.NewStyle() t.Blurred.PrevIndicator = lipgloss.NewStyle() t.Blurred.TextInput.Prompt = lipgloss.NewStyle().Foreground(authLoginMuted) t.Blurred.TextInput.Text = lipgloss.NewStyle().Foreground(authLoginInk) t.Group.Title = t.Focused.Title t.Group.Description = t.Focused.Description t.Help.ShortKey = authLoginMutedStyle() t.Help.ShortDesc = authLoginMutedStyle() t.Help.ShortSeparator = authLoginMutedStyle() t.Help.FullKey = authLoginMutedStyle() t.Help.FullDesc = authLoginMutedStyle() t.Help.FullSeparator = authLoginMutedStyle() t.Help.Ellipsis = authLoginMutedStyle() return t } func authLoginStatusLine(message string) string { return fmt.Sprintf("%s %s", lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true).Render("[OK]"), lipgloss.NewStyle().Foreground(authLoginInk).Bold(true).Render(message), ) } func authLoginInfoLine(key, value string) string { label := authLoginMutedStyle().Width(14).Render(key + ":") return fmt.Sprintf("%s %s", label, value) } func authLoginMutedStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(authLoginMuted) } func authLoginShouldShowPostLoginTUIForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool { return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, interactive) } func authLoginShouldUsePostLoginTUIMode(cmd *cobra.Command, format string, recommend bool) bool { return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal()) } func authLoginShouldUsePostLoginTUIModeForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool { if recommend || !interactive { return false } return authLoginAllowsInteractiveDefault(cmd, format) } func authLoginShouldUseHumanAuthorizationMode(cmd *cobra.Command, format string, hasAuthorizationFlow bool) bool { return authLoginShouldUseHumanAuthorizationModeForTerminal(cmd, format, hasAuthorizationFlow, authLoginInteractiveTerminal()) } func authLoginShouldUseHumanAuthorizationModeForTerminal(cmd *cobra.Command, format string, hasAuthorizationFlow bool, interactive bool) bool { if !hasAuthorizationFlow || !interactive { return false } return authLoginAllowsInteractiveDefault(cmd, format) } func authLoginRecommendPlanSkipsInteractiveAuthorization(plan *pat.LoginRecommendPlan) bool { if plan == nil { return false } return plan.AllGranted || len(plan.Scopes) == 0 } func authLoginAllowsInteractiveDefault(cmd *cobra.Command, format string) bool { if cmd == nil || cmd.Root() == nil { return false } if !strings.EqualFold(strings.TrimSpace(format), "json") { return true } flags := cmd.Root().PersistentFlags() return !flags.Changed("format") } func loginRecommendProductLabel(product pat.LoginRecommendProduct) string { name := strings.TrimSpace(product.ProductName) if name == "" || name == product.ProductCode { name = product.ProductCode } summary := strings.TrimSpace(product.Summary) if summary != "" { summary = " - " + clipRunes(summary, 42) } return fmt.Sprintf("%-10s %s%s", product.ProductCode, name, summary) } func clipRunes(value string, limit int) string { if limit <= 0 { return "" } runes := []rune(value) if len(runes) <= limit { return value } return string(runes[:limit]) + "..." } func clearCompatCache() { // Cache store removed; no-op in static endpoint mode. } func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { token, err := cmd.Flags().GetString("token") if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --token") } device, err := cmd.Flags().GetBool("device") if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --device") } intl, err := cmd.Flags().GetBool("intl") if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --intl") } international, err := cmd.Flags().GetBool("international") if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --international") } force, err := cmd.Flags().GetBool("force") if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --force") } recommend, err := cmd.Flags().GetBool("recommend") if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend") } preURL, err := cmd.Flags().GetString("pre-url") if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --pre-url") } mcpURL, err := cmd.Flags().GetString("mcp-url") if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --mcp-url") } yes := false profileSelector := "" if cmd.Root() != nil { yes, _ = cmd.Root().PersistentFlags().GetBool("yes") profileSelector, _ = cmd.Root().PersistentFlags().GetString("profile") } targetCorpID, historyProfileSelector, historyProfileSelectorExplicit, err := resolveAuthLoginTarget(defaultConfigDir(), profileSelector) if err != nil { return authLoginConfig{}, err } flow := "oauth" if strings.TrimSpace(token) != "" { flow = "token" } else if device { flow = "device" } logging.AuthDebug( "auth.login.request", "flow", flow, "profile_selector", strings.TrimSpace(profileSelector), "target_corp_id", targetCorpID, "history_profile_selector", historyProfileSelector, "recommend", recommend, ) return authLoginConfig{ Token: strings.TrimSpace(token), Force: force, Device: device, Recommend: recommend, Yes: yes, TargetCorpID: targetCorpID, HistoryProfileSelector: historyProfileSelector, HistoryProfileSelectorExplicit: historyProfileSelectorExplicit, International: intl || international, PreURL: strings.TrimSpace(preURL), MCPURL: strings.TrimSpace(mcpURL), }, nil } func authLoginEndpointOverridesForPreURL(raw string) (authLoginEndpointOverrides, error) { parsed, normalized, err := normalizeAuthLoginBaseURL(raw, "--pre-url") if err != nil { return authLoginEndpointOverrides{}, err } host := strings.ToLower(parsed.Hostname()) switch { case strings.HasPrefix(host, "pre-login."): return authLoginEndpointOverrides{ LoginURL: normalized, MCPURL: authLoginURLWithHost(parsed, "pre-mcp."+strings.TrimPrefix(host, "pre-login.")), }, nil case strings.HasPrefix(host, "pre-mcp."): return authLoginEndpointOverrides{ LoginURL: authLoginURLWithHost(parsed, "pre-login."+strings.TrimPrefix(host, "pre-mcp.")), MCPURL: normalized, }, nil default: return authLoginEndpointOverrides{}, apperrors.NewValidation("--pre-url must be a pre-login.* or pre-mcp.* URL") } } func authLoginMCPBaseURLForConfig(cfg authLoginConfig, preOverrides authLoginEndpointOverrides) (string, authLoginMCPPersistence, error) { if cfg.MCPURL != "" { _, normalized, err := normalizeAuthLoginBaseURL(cfg.MCPURL, "--mcp-url") if err != nil { return "", authLoginMCPUseDefault, err } return normalized, authLoginMCPUseExplicitOverride, nil } if cfg.PreURL != "" { if preOverrides.MCPURL == "" { var err error preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL) if err != nil { return "", authLoginMCPUseDefault, err } } return preOverrides.MCPURL, authLoginMCPUseExplicitOverride, nil } if cfg.International { return authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion, nil } return authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault, nil } func persistAuthLoginMCPBaseURL(configDir, mcpBaseURL string, persistence authLoginMCPPersistence) error { mcpURLPath := filepath.Join(configDir, "mcp_url") managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName) switch persistence { case authLoginMCPUseExplicitOverride: if err := removeAuthLoginManagedMCPRegion(managedRegionPath); err != nil { return fmt.Errorf("clear managed MCP region: %w", err) } if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil { return fmt.Errorf("save explicit MCP URL: %w", err) } return nil case authLoginMCPUseManagedRegion: managedURL, managedErr := authReadFile(managedRegionPath) if managedErr != nil && !os.IsNotExist(managedErr) { return fmt.Errorf("read managed MCP region: %w", managedErr) } currentURL, currentErr := authReadFile(mcpURLPath) switch { case currentErr == nil && os.IsNotExist(managedErr): return nil case currentErr == nil && strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)): return removeAuthLoginManagedMCPRegion(managedRegionPath) case currentErr != nil && !os.IsNotExist(currentErr): return fmt.Errorf("read MCP URL: %w", currentErr) } if err := authAtomicWrite(managedRegionPath, []byte(mcpBaseURL), config.FilePerm); err != nil { return fmt.Errorf("save managed MCP region: %w", err) } if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil { _ = authRemove(managedRegionPath) return fmt.Errorf("save managed MCP URL: %w", err) } return nil case authLoginMCPUseDefault: managedURL, err := authReadFile(managedRegionPath) if os.IsNotExist(err) { return nil } if err != nil { return fmt.Errorf("read managed MCP region: %w", err) } currentURL, err := authReadFile(mcpURLPath) if os.IsNotExist(err) { return removeAuthLoginManagedMCPRegion(managedRegionPath) } if err != nil { return fmt.Errorf("read MCP URL: %w", err) } if strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)) { return removeAuthLoginManagedMCPRegion(managedRegionPath) } if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil { return fmt.Errorf("restore default MCP URL: %w", err) } return removeAuthLoginManagedMCPRegion(managedRegionPath) default: return fmt.Errorf("unsupported MCP persistence mode %d", persistence) } } func removeAuthLoginManagedMCPRegion(path string) error { if err := authRemove(path); err != nil && !os.IsNotExist(err) { return err } return nil } func normalizeAuthLoginBaseURL(raw, flagName string) (*url.URL, string, error) { value := strings.TrimSpace(raw) if value == "" { return nil, "", apperrors.NewValidation(flagName + " cannot be empty") } if !strings.Contains(value, "://") { value = "https://" + value } parsed, err := url.Parse(value) if err != nil { return nil, "", apperrors.NewValidation(fmt.Sprintf("invalid %s: %v", flagName, err)) } if parsed.Scheme != "http" && parsed.Scheme != "https" { return nil, "", apperrors.NewValidation(flagName + " must use http or https") } if parsed.Hostname() == "" { return nil, "", apperrors.NewValidation(flagName + " must include a host") } if parsed.Scheme == "http" && !isAuthLoginLoopbackHost(parsed.Hostname()) { return nil, "", apperrors.NewValidation(flagName + " must use HTTPS, except for a loopback HTTP test endpoint") } parsed.RawQuery = "" parsed.Fragment = "" parsed.Path = strings.TrimRight(parsed.Path, "/") return parsed, strings.TrimRight(parsed.String(), "/"), nil } func isAuthLoginLoopbackHost(host string) bool { if strings.EqualFold(strings.TrimSpace(host), "localhost") { return true } ip := net.ParseIP(strings.TrimSpace(host)) return ip != nil && ip.IsLoopback() } func authLoginURLWithHost(parsed *url.URL, host string) string { copyURL := *parsed if port := parsed.Port(); port != "" { copyURL.Host = net.JoinHostPort(host, port) } else { copyURL.Host = host } return strings.TrimRight(copyURL.String(), "/") } func authLoginForcesAuthorization(_ authLoginConfig) bool { return true } func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) { targetCorpID, _, _, err := resolveAuthLoginTarget(configDir, selector) return targetCorpID, err } // resolveAuthLoginTarget keeps the authorization target separate from the // local identity hint used only when contact cannot resolve the logged-in // account. An implicit current profile must never constrain a fresh OAuth // authorization to that profile's organization. func resolveAuthLoginTarget(configDir, selector string) (targetCorpID, historySelector string, explicit bool, err error) { selector = strings.TrimSpace(selector) if selector == "" { if profile, resolveErr := authResolveProfile(configDir, ""); resolveErr == nil && profile != nil { return "", authLoginHistorySelector(configDir, profile), false, nil } return "", "", false, nil } if profile, err := authResolveProfile(configDir, selector); err == nil && profile != nil { historySelector := authLoginHistorySelector(configDir, profile) _, _, identityExact := authpkg.ParseIdentitySelector(selector) if selector != strings.TrimSpace(profile.CorpID) && selector != strings.TrimSpace(profile.CorpName) { identityExact = true } return strings.TrimSpace(profile.CorpID), historySelector, identityExact, nil } if _, _, exact := authpkg.ParseIdentitySelector(selector); exact || strings.Contains(selector, ":") { return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector)) } if strings.HasPrefix(selector, "ding") { // A known organization that failed resolution is ambiguous (for // example, two local accounts without an org-current pointer), not a // request to invent a new corpId. if cfg, loadErr := authLoadProfiles(configDir); loadErr == nil && cfg != nil { for i := range cfg.Profiles { if strings.TrimSpace(cfg.Profiles[i].CorpID) == selector { return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q is ambiguous; use an exact corpId:userId selector", selector)) } } } return selector, "", false, nil } return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector)) } func authLoginHistorySelector(configDir string, profile *authpkg.Profile) string { if profile == nil { return "" } if cfg, err := authLoadProfiles(configDir); err == nil && cfg != nil { return authpkg.ProfileSelectionSelector(*profile, cfg) } return authpkg.ProfileSelector(*profile) } type contactProfileIdentity struct { CorpID string CorpName string UserID string UserName string } type authLoginHistoryHint struct { Selector string Explicit bool } type tokenOverrideToolCaller interface { CallToolWithToken(ctx context.Context, token, productID, toolName string, args map[string]any) (*edition.ToolResult, error) } func enrichAuthLoginProfileFromContact( ctx context.Context, configDir string, caller edition.ToolCaller, data *authpkg.TokenData, hints ...authLoginHistoryHint, ) error { if data == nil { return nil } corpID := strings.TrimSpace(data.CorpID) if corpID == "" { return nil } logging.AuthDebug( "auth.login.identity.lookup.start", "corp_id", corpID, "user_id", strings.TrimSpace(data.UserID), "user_name", strings.TrimSpace(data.UserName), "corp_name", strings.TrimSpace(data.CorpName), ) if strings.TrimSpace(data.CorpName) != "" && strings.TrimSpace(data.UserID) != "" && strings.TrimSpace(data.UserName) != "" { logging.AuthDebug( "auth.login.identity.lookup.result", "source", "token_exchange", "corp_id", corpID, "user_id", strings.TrimSpace(data.UserID), "user_name", strings.TrimSpace(data.UserName), "corp_name", strings.TrimSpace(data.CorpName), ) return nil } hint := authLoginHistoryHint{} if len(hints) > 0 { hint = hints[0] } tryHistory := func() bool { reused, historyErr := enrichAuthLoginProfileFromHistory(configDir, data, hint) if historyErr != nil { logging.AuthDebug( "auth.login.identity.history.error", "corp_id", corpID, "error", historyErr, ) } return reused } if caller == nil { if strings.TrimSpace(data.UserID) == "" { tryHistory() } return nil } var ( result *edition.ToolResult err error ) if tokenCaller, ok := caller.(tokenOverrideToolCaller); ok && strings.TrimSpace(data.AccessToken) != "" { result, err = tokenCaller.CallToolWithToken(ctx, data.AccessToken, "contact", "get_current_user_profile", nil) } else { if strings.TrimSpace(data.UserID) == "" { tryHistory() } return nil } if err != nil { logging.AuthDebug( "auth.login.identity.lookup.error", "corp_id", corpID, "existing_user_id", strings.TrimSpace(data.UserID), "error", err, ) if strings.TrimSpace(data.UserID) != "" { return nil } tryHistory() return nil } identity, ok := contactProfileIdentityFromToolResult(result, corpID) if !ok { logging.AuthDebug("auth.login.identity.lookup.empty", "corp_id", corpID) if strings.TrimSpace(data.UserID) == "" { tryHistory() } return nil } logging.AuthDebug( "auth.login.identity.lookup.result", "source", "contact.get_current_user_profile", "corp_id", strings.TrimSpace(identity.CorpID), "user_id", strings.TrimSpace(identity.UserID), "user_name", strings.TrimSpace(identity.UserName), "corp_name", strings.TrimSpace(identity.CorpName), ) if identity.CorpID != "" && identity.CorpID != corpID { logging.AuthDebug( "auth.login.identity.lookup.mismatch", "login_corp_id", corpID, "contact_corp_id", strings.TrimSpace(identity.CorpID), ) if strings.TrimSpace(data.UserID) == "" { tryHistory() } return nil } updated := *data exchangeUserID := strings.TrimSpace(data.UserID) if identity.CorpName != "" { updated.CorpName = identity.CorpName } if exchangeUserID == "" && identity.UserID != "" { updated.UserID = identity.UserID } if identity.UserName != "" && (exchangeUserID == "" || identity.UserID == "" || identity.UserID == exchangeUserID) { updated.UserName = identity.UserName } if strings.TrimSpace(updated.UserID) == "" { reused, historyErr := enrichAuthLoginProfileFromHistory(configDir, &updated, hint) if historyErr != nil { logging.AuthDebug( "auth.login.identity.history.error", "corp_id", corpID, "error", historyErr, ) } if reused { *data = updated return nil } } if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName { logging.AuthDebug( "auth.login.identity.resolved", "corp_id", corpID, "user_id", strings.TrimSpace(data.UserID), "user_name", strings.TrimSpace(data.UserName), "changed", false, ) return nil } *data = updated logging.AuthDebug( "auth.login.identity.resolved", "corp_id", strings.TrimSpace(data.CorpID), "user_id", strings.TrimSpace(data.UserID), "user_name", strings.TrimSpace(data.UserName), "changed", true, ) return nil } // enrichAuthLoginProfileFromHistory recovers display metadata when the contact // service cannot describe an external-worker account. Historical profile // selection is never proof of the user who completed a fresh authorization: // only the token exchange or contact service may supply UserID. // // An explicit profile remains useful as a storage/selection hint. Keeping it in // LegacyOrgScopedProfile prevents the login from switching the process-global // current profile while SaveTokenData publishes the UID-less credential to the // unresolved organization slot. A historical blank profile is updated in // place; an exact historical profile and its token remain untouched. func enrichAuthLoginProfileFromHistory(configDir string, data *authpkg.TokenData, hints ...authLoginHistoryHint) (bool, error) { if data == nil || strings.TrimSpace(data.UserID) != "" { return false, nil } corpID := strings.TrimSpace(data.CorpID) if corpID == "" { return false, nil } cfg, err := authLoadProfiles(configDir) if err != nil { return false, err } if cfg == nil { return false, nil } sameCorp := make([]*authpkg.Profile, 0, len(cfg.Profiles)) for i := range cfg.Profiles { profile := &cfg.Profiles[i] if strings.TrimSpace(profile.CorpID) != corpID { continue } sameCorp = append(sameCorp, profile) } if len(sameCorp) == 0 { return false, nil } hint := authLoginHistoryHint{} if len(hints) > 0 { hint = hints[0] } var candidate *authpkg.Profile if hint.Explicit { candidate = historicalProfileForSelector(corpID, hint.Selector, sameCorp) if candidate == nil { // An explicit account is a hard identity boundary. If that exact // historical hint no longer matches the token's organization, do // not silently substitute org-current, sole, or global-current. return false, nil } } else if len(sameCorp) > 1 { // Organization-current is a storage preference, not proof of which user // completed a fresh authorization. With multiple accounts, only an exact // user selection may be used when the token/contact response has no UID. return false, nil } else { candidate = sameCorp[0] } updated := *data if hint.Explicit { updated.LegacyOrgScopedProfile = strings.TrimSpace(hint.Selector) } if strings.TrimSpace(updated.CorpName) == "" { updated.CorpName = strings.TrimSpace(candidate.CorpName) } if strings.TrimSpace(updated.UserName) == "" { updated.UserName = strings.TrimSpace(candidate.UserName) } *data = updated logging.AuthDebug( "auth.login.identity.resolved", "source", "local_profile_history_display_only", "corp_id", corpID, "user_id", updated.UserID, "user_name", updated.UserName, "corp_name", updated.CorpName, "identity_proven", false, ) return true, nil } func historicalProfileForSelector(corpID, selector string, profiles []*authpkg.Profile) *authpkg.Profile { selector = strings.TrimSpace(selector) if selector == "" { return nil } cfg := &authpkg.ProfilesConfig{Profiles: make([]authpkg.Profile, 0, len(profiles))} for _, profile := range profiles { if profile != nil { cfg.Profiles = append(cfg.Profiles, *profile) } } var stableMatch *authpkg.Profile for _, profile := range profiles { if profile == nil || strings.TrimSpace(profile.CorpID) != strings.TrimSpace(corpID) || authpkg.ProfileSelectionSelector(*profile, cfg) != selector { continue } if stableMatch != nil { return nil } stableMatch = profile } if stableMatch != nil { return stableMatch } if selectedCorpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact { if strings.TrimSpace(selectedCorpID) != strings.TrimSpace(corpID) { return nil } for _, profile := range profiles { if profile != nil && strings.TrimSpace(profile.UserID) == strings.TrimSpace(userID) { return profile } } return nil } var named *authpkg.Profile for _, profile := range profiles { if profile == nil || strings.TrimSpace(profile.Name) != selector { continue } if named != nil { return nil } named = profile } if named != nil { return named } if selector == strings.TrimSpace(corpID) && len(profiles) == 1 { return profiles[0] } return nil } func contactProfileIdentityFromToolResult(result *edition.ToolResult, expectedCorpIDs ...string) (contactProfileIdentity, bool) { if result == nil { return contactProfileIdentity{}, false } for _, block := range result.Content { if strings.TrimSpace(block.Text) == "" { continue } if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text), expectedCorpIDs...); ok { return identity, true } } return contactProfileIdentity{}, false } func contactProfileIdentityFromJSON(data []byte, expectedCorpIDs ...string) (contactProfileIdentity, bool) { var payload struct { Result []struct { OrgEmployeeModel struct { CorpID string `json:"corpId"` OrgName string `json:"orgName"` UserID string `json:"userId"` UserIDLower string `json:"userid"` OrgUserID string `json:"orgUserId"` OrgUserName string `json:"orgUserName"` Name string `json:"name"` } `json:"orgEmployeeModel"` } `json:"result"` } if err := json.Unmarshal(data, &payload); err != nil { return contactProfileIdentity{}, false } if len(payload.Result) == 0 { return contactProfileIdentity{}, false } identities := make([]contactProfileIdentity, 0, len(payload.Result)) for i := range payload.Result { org := payload.Result[i].OrgEmployeeModel identity := contactProfileIdentity{ CorpID: strings.TrimSpace(org.CorpID), CorpName: strings.TrimSpace(org.OrgName), UserID: firstNonEmptyString(org.UserID, org.UserIDLower, org.OrgUserID), UserName: firstNonEmptyString(org.OrgUserName, org.Name), } if identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != "" { identities = append(identities, identity) } } if len(identities) == 0 { return contactProfileIdentity{}, false } expectedCorpID := "" if len(expectedCorpIDs) > 0 { expectedCorpID = strings.TrimSpace(expectedCorpIDs[0]) } if expectedCorpID != "" { for _, identity := range identities { if identity.CorpID == expectedCorpID { return identity, true } } // Older contact responses omit corpId. A single result is still // unambiguous; multiple organization records without a target match // must fall back to local history instead of choosing result[0]. if len(payload.Result) != 1 { return contactProfileIdentity{}, false } } return identities[0], true } func firstNonEmptyString(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed } } return "" } func authStatusAuthenticated(data *authpkg.TokenData) bool { if data == nil { return false } return data.IsAccessTokenValid() || data.IsRefreshTokenValid() } func authStatusUpdatedAt(data *authpkg.TokenData) string { if data == nil { return "" } if data.IsAccessTokenValid() { return timeOrEmpty(data.ExpiresAt) } if data.IsRefreshTokenValid() { return timeOrEmpty(data.RefreshExpAt) } return "" } // authStatusResponse is the JSON response for auth status command. type authStatusResponse struct { Success bool `json:"success"` Authenticated bool `json:"authenticated"` Message string `json:"message,omitempty"` Reason string `json:"reason,omitempty"` Hint string `json:"hint,omitempty"` Refreshed bool `json:"refreshed,omitempty"` TokenValid bool `json:"token_valid,omitempty"` RefreshTokenValid bool `json:"refresh_token_valid,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` RefreshExpiresAt string `json:"refresh_expires_at,omitempty"` CorpID string `json:"corp_id,omitempty"` CorpName string `json:"corp_name,omitempty"` UserID string `json:"user_id,omitempty"` UserName string `json:"user_name,omitempty"` } type authStatusDiagnostic struct { Reason string Message string Hint string } func authStatusDiagnosticFromError(err error) *authStatusDiagnostic { if err == nil { return nil } if keychain.IsCiphertextKeyMismatch(err) { return &authStatusDiagnostic{ Reason: "ciphertext_key_mismatch", Message: "本地登录态与可用登录密钥不匹配,已拒绝覆盖现有凭证", Hint: "macOS 请先在系统 Keychain 模式运行 `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`,预检通过后加 --yes 迁移;只有密文损坏且确认无法恢复时才按 profile 退出或执行 auth reset。", } } if keychain.IsDEKMissing(err) { return &authStatusDiagnostic{ Reason: "dek_missing", Message: "本地登录密钥缺失,无法解密已保存的登录态", Hint: "请先恢复或统一原登录密钥;确认旧登录态不可恢复后,执行 dws auth reset,再重新登录。", } } if !keychain.IsUnavailable(err) { return nil } return &authStatusDiagnostic{ Reason: "keychain_unavailable", Message: "无法读取 macOS Keychain 中的登录密钥,无法判断登录状态", Hint: "检查 macOS 默认钥匙串是否存在且已解锁;修复后重试,或在测试环境设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。", } } func authStatusRefreshDiagnostic(err error) *authStatusDiagnostic { if err == nil { return nil } return &authStatusDiagnostic{ Reason: "token_refresh_failed", Message: fmt.Sprintf("Token 刷新失败: %v", err), Hint: "请重新运行 dws auth login 完成授权。", } } func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData, diagnostic *authStatusDiagnostic) error { resp := authStatusResponse{ Success: true, Authenticated: authenticated, } if !authenticated { if diagnostic != nil { resp.Message = diagnostic.Message resp.Reason = diagnostic.Reason resp.Hint = diagnostic.Hint } else { resp.Message = "未登录" } } else if data != nil { resp.Refreshed = refreshed resp.TokenValid = data.IsAccessTokenValid() resp.RefreshTokenValid = data.IsRefreshTokenValid() if !data.ExpiresAt.IsZero() { resp.ExpiresAt = data.ExpiresAt.Format(time.RFC3339Nano) } if !data.RefreshExpAt.IsZero() { resp.RefreshExpiresAt = data.RefreshExpAt.Format(time.RFC3339Nano) } resp.CorpID = data.CorpID resp.CorpName = data.CorpName resp.UserID = data.UserID resp.UserName = data.UserName } enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(resp) } // authLoginResponse is the JSON response for auth login command. type authLoginResponse struct { Success bool `json:"success"` Message string `json:"message"` TokenValid bool `json:"token_valid,omitempty"` RefreshTokenValid bool `json:"refresh_token_valid,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` RefreshExpiresAt string `json:"refresh_expires_at,omitempty"` CorpID string `json:"corp_id,omitempty"` CorpName string `json:"corp_name,omitempty"` UserID string `json:"user_id,omitempty"` UserName string `json:"user_name,omitempty"` } func writeAuthLoginJSON(w io.Writer, data *authpkg.TokenData, forced bool) error { resp := authLoginResponse{ Success: true, Message: "登录成功", } if data != nil { if data.IsAccessTokenValid() && !forced { resp.Message = "Token 有效,无需重新登录" } resp.TokenValid = data.IsAccessTokenValid() resp.RefreshTokenValid = data.IsRefreshTokenValid() if !data.ExpiresAt.IsZero() { resp.ExpiresAt = data.ExpiresAt.Format(time.RFC3339Nano) } if !data.RefreshExpAt.IsZero() { resp.RefreshExpiresAt = data.RefreshExpAt.Format(time.RFC3339Nano) } resp.CorpID = data.CorpID resp.CorpName = data.CorpName resp.UserID = data.UserID resp.UserName = data.UserName } enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(resp) }