Files

2116 lines
74 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 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 <corpId> # 指定本次授权目标组织,不持久切换当前组织
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 <corpId>
dws auth logout --profile <corpId>:<userId>
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 <corpId>
dws auth status --profile <corpId>:<userId>
dws auth status --profile "钉钉:孙博文"
dws auth status --profile <corpId> --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)
}