// 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 auth import ( "context" "encoding/json" "errors" "fmt" "html" "io" "log/slog" "net" "net/http" "os" "strconv" "strings" "sync" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging" ) // oauthHTTPClient is a dedicated HTTP client for OAuth operations with // explicit timeout and TLS configuration, replacing http.DefaultClient. var oauthHTTPClient = &http.Client{ Timeout: 15 * time.Second, } var ( oauthListen = net.Listen oauthOpenBrowser = openBrowser oauthLoginTimeout = 5 * time.Minute oauthApprovalTimeout = 10 * time.Minute oauthPollInterval = 5 * time.Second oauthSuccessPause = 2 * time.Second oauthLoadToken = LoadTokenData oauthLoadTokenLocked = loadTokenDataForProfileLocked oauthAcquireLock = AcquireDualLock oauthMarkProfile = MarkProfileStatus oauthFetchClientID = FetchClientIDFromMCP oauthExchange = func(p *OAuthProvider, ctx context.Context, code string) (*TokenData, error) { return p.exchangeCode(ctx, code) } oauthCheckStatus = func(p *OAuthProvider, ctx context.Context, token string) (*CLIAuthStatus, error) { return p.CheckCLIAuthEnabled(ctx, token) } oauthGetAdmins = GetSuperAdmins oauthSendApply = SendCliAuthApply oauthSaveToken = SaveTokenData oauthHasAppConfig = HasAppConfig oauthSaveAppConfig = SaveAppConfig oauthRefreshToken = func(p *OAuthProvider, ctx context.Context, data *TokenData) (*TokenData, error) { return p.refreshWithRefreshToken(ctx, data) } oauthSleep = time.Sleep ) func oauthFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) { if region.IsInternational() { return FetchClientIDFromMCPForLoginRegion(ctx, region) } return oauthFetchClientID(ctx) } func oauthGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) { if region.IsInternational() { return GetSuperAdminsForLoginRegion(ctx, accessToken, region) } return oauthGetAdmins(ctx, accessToken) } func oauthSendApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) { if region.IsInternational() { return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region) } return oauthSendApply(ctx, accessToken, adminStaffID) } // OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow. type OAuthProvider struct { configDir string clientID string logger *slog.Logger Output io.Writer httpClient *http.Client NoBrowser bool TargetCorpID string // IdentityEnricher resolves userId/userName/corpName while the freshly // exchanged access token is still only in memory. IdentityEnricher func(context.Context, *TokenData) error LoginRegion LoginRegion } // NewOAuthProvider creates a new OAuth provider. func NewOAuthProvider(configDir string, logger *slog.Logger) *OAuthProvider { return &OAuthProvider{ configDir: configDir, clientID: ClientID(), logger: logger, Output: os.Stderr, httpClient: oauthHTTPClient, } } // resetCredentialState clears any stale credential state inherited from // previous login methods before the OAuth flow falls back to MCP-managed // credentials. Complete runtime AppKey/AppSecret overrides skip this reset. func (p *OAuthProvider) resetCredentialState() { p.clientID = "" clientMu.Lock() clientIDFromMCP = false clientMu.Unlock() } func (p *OAuthProvider) output() io.Writer { if p != nil && p.Output != nil { return p.Output } return io.Discard } // Login performs authentication with smart degradation: // 1. If force=false, try silent token refresh first (refresh_token) // 2. If all silent methods fail (or force=true), fall back to browser OAuth flow func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, error) { // Smart degradation: try silent refresh before opening browser. if !force { data, err := oauthLoadToken(p.configDir) if err != nil && !errors.Is(err, ErrTokenDataNotFound) && !os.IsNotExist(err) { // A damaged selected slot must not make browser reauthorization // impossible. The target identity is unknown until token exchange, so // continue into the full flow and let the target-only preflight reject // an unsafe overwrite after identity enrichment. if p.logger != nil { p.logger.Warn(i18n.T("读取现有登录态失败,将尝试扫码登录"), "error", err) } } if err == nil { // Case 1: access_token still valid — no action needed. if data.IsAccessTokenValid() { if p.logger != nil { p.logger.Debug("access_token still valid, skipping login") } // Even on early return, persist custom app credentials if provided // via --client-id/--client-secret flags. Without this, the flags // are only in runtime globals and lost when the process exits. p.persistAppConfigIfNeeded() return data, nil } // Case 2: refresh using refresh_token (with lock to prevent concurrent refresh). if data.IsRefreshTokenValid() { if p.logger != nil { p.logger.Debug("access_token expired, trying refresh_token") } refreshed, rErr := p.lockedRefresh(ctx) if rErr == nil { p.persistAppConfigIfNeeded() return refreshed, nil } if p.logger != nil { p.logger.Warn(i18n.T("refresh_token 刷新失败,将尝试扫码登录"), "error", rErr) } } } } if err := prepareLoginPersistence(p.configDir); err != nil { return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err) } // Fall through: full browser OAuth flow. if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok { p.clientID = runtimeClientID clientMu.Lock() clientIDFromMCP = false clientMu.Unlock() } else { // Defensive reset: clear any stale credential state from previous login // methods so we can re-fetch clientID from MCP. This ensures --force // login works regardless of what app.json contains. p.resetCredentialState() if p.logger != nil { p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)") } mcpClientID, mcpErr := oauthFetchClientIDForLoginRegion(ctx, p.LoginRegion) if mcpErr != nil { return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) } p.clientID = mcpClientID SetClientIDFromMCP(mcpClientID) if p.logger != nil { p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID) } } // Find a free port for the callback server. listener, err := oauthListen("tcp", "127.0.0.1:0") if err != nil { return nil, fmt.Errorf("starting callback listener: %w", err) } port := listener.Addr().(*net.TCPAddr).Port redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", port, CallbackPath) logging.AuthDebug( "auth.login.oauth.flow.start", "client_id", strings.TrimSpace(p.clientID), "target_corp_id", strings.TrimSpace(p.TargetCorpID), "callback_port", port, "force", force, "no_browser", p.NoBrowser, ) // Channel to pass callback result (token data or error with CLI auth status) type callbackResult struct { token *TokenData err error cliAuthDisabled bool denialReason string errorMsg string // server-provided errorMsg from /cli/cliAuthEnabled } resultCh := make(chan callbackResult, 1) errCh := make(chan error, 1) // Shared state for API handlers (protected by mutex) var ( callbackToken *TokenData callbackProcessedCode string // The auth code that has been successfully processed callbackAuthDisabled bool callbackApplySent bool // Whether apply request was sent callbackSelectedAdminId string // Selected admin ID for apply callbackCodeInProgress string // Code currently being processed (to prevent concurrent exchange) callbackTokenMu sync.Mutex ) mux := http.NewServeMux() mux.HandleFunc(CallbackPath, func(w http.ResponseWriter, r *http.Request) { // Get code first to check if this is a new authorization or page refresh code := r.URL.Query().Get("authCode") if code == "" { code = r.URL.Query().Get("code") } logging.AuthDebug( "auth.login.oauth.callback.received", "callback_port", port, "has_authorization_code", code != "", ) // Check state and handle page refresh or concurrent requests callbackTokenMu.Lock() processedCode := callbackProcessedCode processedAuthDisabled := callbackAuthDisabled codeInProgress := callbackCodeInProgress hasToken := callbackToken != nil // Case 1: This code was already successfully processed - show cached page if code != "" && code == processedCode { callbackTokenMu.Unlock() w.Header().Set("Content-Type", "text/html; charset=utf-8") if processedAuthDisabled { _, _ = fmt.Fprint(w, notEnabledHTML) } else { _, _ = fmt.Fprint(w, successHTML) } return } // Case 2: This code is being processed by another request - show wait page if code != "" && code == codeInProgress { callbackTokenMu.Unlock() w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = fmt.Fprint(w, `
正在处理授权,请稍候...
`) return } // Case 3: No code but we have a processed token - show cached page if code == "" && hasToken { callbackTokenMu.Unlock() w.Header().Set("Content-Type", "text/html; charset=utf-8") if processedAuthDisabled { _, _ = fmt.Fprint(w, notEnabledHTML) } else { _, _ = fmt.Fprint(w, successHTML) } return } // Case 4: New code - mark as in-progress and process if code != "" { callbackCodeInProgress = code } callbackTokenMu.Unlock() if code == "" { select { case errCh <- errors.New(i18n.T("回调中未收到授权码")): default: } w.WriteHeader(http.StatusBadRequest) _, _ = fmt.Fprint(w, i18n.T("授权失败:未收到授权码")) return } // Exchange code for token tokenData, exchangeErr := oauthExchange(p, ctx, code) if exchangeErr != nil { logging.AuthDebug( "auth.login.oauth.token_exchange.error", "callback_port", port, "error", exchangeErr, ) // Clear in-progress state on error callbackTokenMu.Lock() if callbackCodeInProgress == code { callbackCodeInProgress = "" } callbackTokenMu.Unlock() w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = fmt.Fprintf(w, "%s
", html.EscapeString(oauthExchangeDisplayError(exchangeErr))) select { case resultCh <- callbackResult{err: exchangeErr}: default: } return } logging.AuthDebug( "auth.login.oauth.token_exchange.done", "callback_port", port, "corp_id", strings.TrimSpace(tokenData.CorpID), "user_id", strings.TrimSpace(tokenData.UserID), "user_name", strings.TrimSpace(tokenData.UserName), "source", strings.TrimSpace(tokenData.Source), "access_expires_at", tokenData.ExpiresAt, "refresh_expires_at", tokenData.RefreshExpAt, ) // Mark as processed immediately after successful exchange callbackTokenMu.Lock() previouslyProcessed := callbackProcessedCode != "" callbackToken = tokenData callbackProcessedCode = code // Remember this code was successfully processed callbackCodeInProgress = "" // Clear in-progress state // Reset apply state for new authorization (user switched org) if previouslyProcessed { callbackApplySent = false callbackSelectedAdminId = "" } callbackTokenMu.Unlock() // Check CLI auth enabled status (fail-closed: treat errors as disabled) authStatus, statusErr := oauthCheckStatus(p, ctx, tokenData.AccessToken) var denialReason string if statusErr != nil { denialReason = "unknown" } else { denialReason = classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL")) } cliAuthEnabled := denialReason == "" logging.AuthDebug( "auth.login.oauth.organization_access.checked", "callback_port", port, "corp_id", strings.TrimSpace(tokenData.CorpID), "enabled", cliAuthEnabled, "denial_reason", denialReason, ) // Server-provided errorMsg (nil-safe), surfaced both on the page and to // the terminal so portal can update copy without releasing the CLI. serverMsg := "" if authStatus != nil { serverMsg = authStatus.ErrorMsg } // Update CLI auth disabled state callbackTokenMu.Lock() callbackAuthDisabled = !cliAuthEnabled callbackTokenMu.Unlock() // Display appropriate HTML based on auth status and denial reason w.Header().Set("Content-Type", "text/html; charset=utf-8") switch { case cliAuthEnabled: _, _ = fmt.Fprint(w, successHTML) case denialReason == "user_forbidden" || denialReason == "user_not_allowed": _, _ = fmt.Fprint(w, accessDeniedHTML) case denialReason == "channel_not_allowed" || denialReason == "channel_required": _, _ = fmt.Fprint(w, channelDeniedHTML) case denialReason == "enterprise_not_authorized": _, _ = fmt.Fprint(w, renderEnterpriseDeniedHTML(serverMsg)) default: _, _ = fmt.Fprint(w, notEnabledHTML) } // Ensure response is flushed to client if f, ok := w.(http.Flusher); ok { f.Flush() } // Notify main goroutine with full result select { case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason, errorMsg: serverMsg}: default: } }) // API endpoint: get super admins mux.HandleFunc("/api/superAdmin", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") callbackTokenMu.Lock() token := callbackToken callbackTokenMu.Unlock() if token == nil { _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) return } result, err := oauthGetAdminsForLoginRegion(ctx, token.AccessToken, p.LoginRegion) if err != nil { _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) return } data, _ := json.Marshal(result) _, _ = w.Write(data) }) // API endpoint: send CLI auth apply mux.HandleFunc("/api/sendApply", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") adminStaffID := r.URL.Query().Get("adminStaffId") if adminStaffID == "" { _, _ = w.Write([]byte(`{"success":false,"errorMsg":"缺少 adminStaffId 参数"}`)) return } callbackTokenMu.Lock() token := callbackToken callbackTokenMu.Unlock() if token == nil { _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) return } result, err := oauthSendApplyForLoginRegion(ctx, token.AccessToken, adminStaffID, p.LoginRegion) if err != nil { _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) return } // Mark apply as sent and save selected admin on success if result.Success && result.Result { callbackTokenMu.Lock() callbackApplySent = true callbackSelectedAdminId = adminStaffID callbackTokenMu.Unlock() } data, _ := json.Marshal(result) _, _ = w.Write(data) }) // API endpoint: get current status (clientId, applySent, selectedAdminId) mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") callbackTokenMu.Lock() applySent := callbackApplySent selectedAdminId := callbackSelectedAdminId callbackTokenMu.Unlock() data, _ := json.Marshal(map[string]any{ "clientId": p.clientID, "authorizeUrl": AuthorizeURLForLoginRegion(p.LoginRegion), "applySent": applySent, "selectedAdminId": selectedAdminId, }) _, _ = w.Write(data) }) // API endpoint: check CLI auth enabled status mux.HandleFunc("/api/cliAuthEnabled", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") callbackTokenMu.Lock() token := callbackToken callbackTokenMu.Unlock() if token == nil { _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) return } result, err := oauthCheckStatus(p, ctx, token.AccessToken) if err != nil { _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) return } data, _ := json.Marshal(result) _, _ = w.Write(data) }) // Success page endpoint mux.HandleFunc("/success", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = fmt.Fprint(w, successHTML) }) server := &http.Server{Handler: mux} go func() { if serveErr := server.Serve(listener); !errors.Is(serveErr, http.ErrServerClosed) { select { case errCh <- fmt.Errorf("callback server error: %w", serveErr): default: } } }() defer func() { shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutCancel() _ = server.Shutdown(shutCtx) }() authURL := buildAuthURLForRegion(p.clientID, redirectURI, p.TargetCorpID, p.LoginRegion) if p.logger != nil { p.logger.Debug("authorization URL", "url", authURL) } if !p.NoBrowser { if err := oauthOpenBrowser(authURL); err != nil && p.logger != nil { p.logger.Warn(i18n.T("无法自动打开浏览器"), "error", err) } } _, _ = fmt.Fprintln(p.output(), "") _, _ = fmt.Fprintln(p.output(), i18n.T("🔐 登录钉钉")) _, _ = fmt.Fprintln(p.output(), "") _, _ = fmt.Fprintln(p.output(), i18n.T("请在浏览器中完成扫码授权。")) _, _ = fmt.Fprintf(p.output(), i18n.T("如果浏览器未自动打开,请手动访问:\n %s\n\n"), authURL) _, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 等待授权中...")) timeout := time.NewTimer(oauthLoginTimeout) defer timeout.Stop() var result callbackResult select { case result = <-resultCh: case err := <-errCh: return nil, err case <-timeout.C: return nil, errors.New(i18n.T("授权超时(5分钟),请重试")) case <-ctx.Done(): return nil, ctx.Err() } // Handle callback errors if result.err != nil { return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), result.err) } // Handle CLI auth disabled - for terminal denial reasons, exit immediately // (page shows accessDeniedHTML/channelDeniedHTML with no apply button, // so polling for apply submission would hang forever). // Error messages are kept consistent with the text shown on the HTML pages. if result.cliAuthDisabled { switch result.denialReason { case "user_forbidden", "user_not_allowed": return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员将您加入授权名单")) case "channel_not_allowed", "channel_required": return nil, errors.New(i18n.T("当前渠道未获得该组织授权,或组织已开启渠道管控,请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI")) case "enterprise_not_authorized": if msg := strings.TrimSpace(result.errorMsg); msg != "" { return nil, errors.New(msg) } return nil, errors.New(i18n.T("本次请求未通过企业安全认证")) } _, _ = fmt.Fprintln(p.output(), "") _, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...")) // Poll for CLI auth status while waiting applyTimeout := time.NewTimer(oauthApprovalTimeout) defer applyTimeout.Stop() pollTicker := time.NewTicker(oauthPollInterval) defer pollTicker.Stop() elapsedSeconds := 0 for { select { case <-applyTimeout.C: return nil, errors.New(i18n.T("操作超时,请重新登录")) case <-ctx.Done(): return nil, ctx.Err() case <-pollTicker.C: elapsedSeconds += 5 // Get latest token and state (user may have switched org) callbackTokenMu.Lock() currentToken := callbackToken currentAuthDisabled := callbackAuthDisabled applySent := callbackApplySent callbackTokenMu.Unlock() // Check if user switched to an org with CLI auth enabled if currentToken != nil && !currentAuthDisabled { _, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录...")) oauthSleep(oauthSuccessPause) result.token = currentToken result.cliAuthDisabled = false goto continueLogin } // Check if CLI auth is now enabled (admin approved) if currentToken != nil { authStatus, err := oauthCheckStatus(p, ctx, currentToken.AccessToken) if err == nil && classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL")) == "" { _, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录...")) oauthSleep(oauthSuccessPause) result.token = currentToken result.cliAuthDisabled = false goto continueLogin } } // Show polling status based on apply state if applySent { _, _ = fmt.Fprintf(p.output(), "\r⏳ %s (%ds/600s) ", i18n.T("等待管理员审批中"), elapsedSeconds) } else { _, _ = fmt.Fprintf(p.output(), "\r⏳ %s (%ds/600s) ", i18n.T("等待提交申请中"), elapsedSeconds) } } } } continueLogin: tokenData := result.token // Save token data with associated client ID for refresh tokenData.ClientID = p.clientID logging.AuthDebug( "auth.login.oauth.persistence.start", "callback_port", port, "corp_id", strings.TrimSpace(tokenData.CorpID), "user_id", strings.TrimSpace(tokenData.UserID), "user_name", strings.TrimSpace(tokenData.UserName), ) if err := p.persistLoginToken(ctx, tokenData); err != nil { logging.AuthDebug( "auth.login.oauth.persistence.error", "callback_port", port, "corp_id", strings.TrimSpace(tokenData.CorpID), "user_id", strings.TrimSpace(tokenData.UserID), "error", err, ) return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err) } logging.AuthDebug( "auth.login.oauth.persistence.done", "callback_port", port, "corp_id", strings.TrimSpace(tokenData.CorpID), "user_id", strings.TrimSpace(tokenData.UserID), "user_name", strings.TrimSpace(tokenData.UserName), ) // Persist app credentials (with secret) if using custom client credentials. // MUST run BEFORE os.Setenv below to avoid env-matching short circuit. p.persistAppConfigIfNeeded() // Always persist clientId to app.json so future process startups // can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env. if p.clientID != "" { _ = os.Setenv("DWS_CLIENT_ID", p.clientID) if !oauthHasAppConfig(p.configDir) { _ = oauthSaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID}) } } return tokenData, nil } func oauthExchangeDisplayError(err error) string { var statusErr *HTTPStatusError if errors.As(err, &statusErr) && statusErr != nil { return fmt.Sprintf("HTTP %d: token exchange failed", statusErr.StatusCode) } return err.Error() } // GetTokenSnapshot returns a valid token together with its expiry metadata. // Storage and refresh failures retain their original cause; only a confirmed // missing credential is reported as ErrTokenDataNotFound. func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error) { data, err := oauthLoadToken(p.configDir) if err != nil { if errors.Is(err, ErrTokenDataNotFound) || os.IsNotExist(err) { return nil, fmt.Errorf("%s: %w", i18n.T("未登录,请运行 dws auth login"), ErrTokenDataNotFound) } return nil, fmt.Errorf("load access token: %w", err) } profileSelector := StableTokenProfileSelector(p.configDir, data) // Fast path: access_token still valid — no lock needed. if data.IsAccessTokenValid() { return data, nil } // Slow path: token expired — try locked refresh. if data.IsRefreshTokenValid() { refreshed, rErr := p.lockedRefresh(ctx) if rErr == nil { return refreshed, nil } // A network, timeout, rate-limit or 5xx failure does not invalidate the // refresh credential. Keep the profile active so a long-running source // can retry after backoff. Terminal and unknown failures remain fatal. if ClassifyRefreshFailure(rErr) != RefreshFailureTransient { _ = oauthMarkProfile(p.configDir, profileSelector, ProfileStatusExpired) } if p.logger != nil { p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr) } var exchangeErr *MCPTokenExchangeError if errors.As(rErr, &exchangeErr) && exchangeErr.requiresReauthorization() { return nil, fmt.Errorf( "%s: %w", legacyRefreshReauthorizationGuidance(profileSelector), rErr, ) } return nil, fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr) } else { _ = oauthMarkProfile(p.configDir, profileSelector, ProfileStatusExpired) } return nil, fmt.Errorf("%s: %w", i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"), ErrTokenDataNotFound) } func legacyRefreshReauthorizationGuidance(profileSelector string) string { guidance := "旧版登录态已无法由当前认证服务刷新;本地 profile 已保留,请重新运行 dws auth login 完成一次重新授权" profileSelector = strings.TrimSpace(profileSelector) if profileSelector == "" { return guidance } return fmt.Sprintf( "%s;为保留原身份,请把 --profile 参数设置为下方 profile 标识(标识仅作数据展示,不是可执行命令):\nprofile: %s", guidance, strconv.Quote(profileSelector), ) } // GetAccessToken returns a valid access token, auto-refreshing if needed. // Uses a file lock with double-check pattern to prevent concurrent refresh // from multiple CLI processes. func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) { data, err := p.GetTokenSnapshot(ctx) if err != nil { return "", err } return strings.TrimSpace(data.AccessToken), nil } // lockedRefresh attempts to refresh the token while holding dual-layer locks. // It uses a double-check pattern with both process-level and file-level locking: // // Layer 1 (Process Lock - sync.Map): // // Prevents multiple goroutines within the same process from refreshing simultaneously. // If another goroutine is already refreshing, we wait for it and then re-check. // // Layer 2 (File Lock - flock/LockFileEx): // // Prevents multiple CLI processes from refreshing simultaneously. // If another process is refreshing, we wait for the file lock and then re-check. // // Double-Check Pattern: // // After acquiring the lock, we re-load from disk because another goroutine/process // may have already completed the refresh while we were waiting. This prevents the // classic race where two callers both see an expired token and both call the // refresh API, invalidating each other's refresh_token. func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) { // Acquire dual-layer lock (process-level + file-level) lock, err := oauthAcquireLock(ctx, p.configDir) if err != nil { return nil, fmt.Errorf("acquiring dual lock: %w", err) } defer lock.Release() // Double-check: re-load from disk — another goroutine/process may have refreshed // while we were waiting for the lock. data, err := loadOAuthTokenUnderHeldLock(p.configDir, RuntimeProfile()) if err != nil { return nil, err } if data.IsAccessTokenValid() { if p.logger != nil { if lock.Waited { p.logger.Debug("token already refreshed by another goroutine/process") } else { p.logger.Debug("token still valid after acquiring lock") } } return data, nil } // Still expired — we need to actually refresh. if !data.IsRefreshTokenValid() { return nil, fmt.Errorf("refresh_token 已过期") } if err := preflightTokenRefreshPersistence(p.configDir, data); err != nil { return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err) } if p.logger != nil { p.logger.Debug("refreshing token (dual-locked)") } return oauthRefreshToken(p, ctx, data) } // ExchangeAuthCode takes an AuthCode and an optional UserID provided by an // external host, exchanges it for tokens, and persists them. func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) { if err := prepareLoginPersistence(p.configDir); err != nil { return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err) } tokenData, err := oauthExchange(p, ctx, authCode) if err != nil { return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err) } if uid != "" { tokenData.UserID = strings.TrimSpace(uid) if err := p.persistKnownLoginToken(tokenData); err != nil { return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err) } return tokenData, nil } if err := p.persistLoginToken(ctx, tokenData); err != nil { return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err) } return tokenData, nil } func (p *OAuthProvider) persistLoginToken(ctx context.Context, tokenData *TokenData) error { corpID, userID, userName := "", "", "" if tokenData != nil { corpID = strings.TrimSpace(tokenData.CorpID) userID = strings.TrimSpace(tokenData.UserID) userName = strings.TrimSpace(tokenData.UserName) } logging.AuthDebug( "auth.login.oauth.identity.before_enrich", "corp_id", corpID, "user_id", userID, "user_name", userName, ) if err := p.prepareLoginToken(ctx, tokenData); err != nil { return err } logging.AuthDebug( "auth.login.oauth.identity.after_enrich", "corp_id", strings.TrimSpace(tokenData.CorpID), "user_id", strings.TrimSpace(tokenData.UserID), "user_name", strings.TrimSpace(tokenData.UserName), ) if err := preflightTokenWritePersistence(p.configDir, tokenData); err != nil { return fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err) } if err := oauthSaveToken(p.configDir, tokenData); err != nil { return err } return nil } func (p *OAuthProvider) prepareLoginToken(ctx context.Context, tokenData *TokenData) error { if tokenData == nil { return fmt.Errorf("token data is empty") } tokenData.FreshAuthorization = true if p != nil && p.IdentityEnricher != nil { if err := p.IdentityEnricher(ctx, tokenData); err != nil { return fmt.Errorf("resolve login identity: %w", err) } } // v1.0.52 and earlier deliberately persisted the freshly exchanged token // before best-effort contact enrichment. External-worker accounts can have a // valid organization token while contact cannot return a userId, so rejecting // that shape here makes an otherwise successful reauthorization impossible. // SaveTokenData remains the safety boundary: an unresolved organization token // cannot overwrite an organization that already has exact account identities. return nil } func (p *OAuthProvider) persistKnownLoginToken(tokenData *TokenData) error { if tokenData == nil { return fmt.Errorf("token data is empty") } if strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" { return fmt.Errorf("resolve login identity: userId is required for corpId %q", tokenData.CorpID) } if err := preflightTokenWritePersistence(p.configDir, tokenData); err != nil { return fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err) } return oauthSaveToken(p.configDir, tokenData) } // Logout clears all stored credentials. func (p *OAuthProvider) Logout() error { return DeleteTokenData(p.configDir) } // Status returns the current auth status. func (p *OAuthProvider) Status() (*TokenData, error) { return LoadTokenData(p.configDir) } // persistAppConfigIfNeeded saves app credentials if custom ones were used. // This ensures the client secret is available for future token refreshes. func (p *OAuthProvider) persistAppConfigIfNeeded() { // Check if custom credentials were provided via runtime flags clientID, clientSecret := getRuntimeCredentials() if clientID == "" || clientSecret == "" { return } // Skip if using default placeholder credentials if clientID == DefaultClientID { return } // Save app config with secret stored in keychain config := &AppConfig{ ClientID: clientID, ClientSecret: PlainSecret(clientSecret), } if err := SaveAppConfig(p.configDir, config); err != nil { if p.logger != nil { p.logger.Warn("failed to persist app credentials", "error", err) } } }