753 lines
27 KiB
Go
753 lines
27 KiB
Go
// 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"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
|
)
|
|
|
|
const (
|
|
// defaultPollInterval is the default seconds between device token polls.
|
|
// The server-side Redis TTL is 10 minutes; a 2-second interval keeps the
|
|
// user-perceived latency low while staying well within rate limits.
|
|
defaultPollInterval = 2
|
|
// maxPollInterval caps the polling interval to prevent DoS via slow_down.
|
|
maxPollInterval = 30
|
|
// maxPollTotalWait caps the total wait time for device authorization.
|
|
// Aligned with the server-side Redis TTL (10 minutes).
|
|
maxPollTotalWait = 10 * time.Minute
|
|
)
|
|
|
|
var (
|
|
deviceFlowAfter = time.After
|
|
deviceOpenBrowser = openBrowser
|
|
deviceFetchClientID = FetchClientIDFromMCP
|
|
deviceLoginOnce = func(p *DeviceFlowProvider, ctx context.Context, attempt int) (*TokenData, error) {
|
|
return p.loginOnce(ctx, attempt)
|
|
}
|
|
deviceRequestCode = func(p *DeviceFlowProvider, ctx context.Context) (*DeviceAuthResponse, error) {
|
|
return p.requestDeviceCode(ctx)
|
|
}
|
|
deviceWaitAuth = func(p *DeviceFlowProvider, ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
|
|
return p.waitForAuthorization(ctx, auth)
|
|
}
|
|
deviceExchangeCode = func(p *OAuthProvider, ctx context.Context, code string) (*TokenData, error) {
|
|
return p.exchangeCode(ctx, code)
|
|
}
|
|
deviceCheckCLIAuth = func(p *OAuthProvider, ctx context.Context, token string) (*CLIAuthStatus, error) {
|
|
return p.CheckCLIAuthEnabled(ctx, token)
|
|
}
|
|
deviceGetAdmins = GetSuperAdmins
|
|
deviceSaveToken = SaveTokenData
|
|
deviceHasAppConfig = HasAppConfig
|
|
deviceSaveAppConfig = SaveAppConfig
|
|
devicePollStatus = func(p *DeviceFlowProvider, ctx context.Context, flowID string) (*DevicePollResponse, error) {
|
|
return p.pollDeviceStatus(ctx, flowID)
|
|
}
|
|
devicePollToken = func(p *DeviceFlowProvider, ctx context.Context, code string) (*DeviceTokenResponse, error) {
|
|
return p.pollDeviceToken(ctx, code)
|
|
}
|
|
)
|
|
|
|
func deviceFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
|
if region.IsInternational() {
|
|
return FetchClientIDFromMCPForLoginRegion(ctx, region)
|
|
}
|
|
return deviceFetchClientID(ctx)
|
|
}
|
|
|
|
func deviceGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
|
if region.IsInternational() {
|
|
return GetSuperAdminsForLoginRegion(ctx, accessToken, region)
|
|
}
|
|
return deviceGetAdmins(ctx, accessToken)
|
|
}
|
|
|
|
type DeviceFlowProvider struct {
|
|
configDir string
|
|
clientID string
|
|
scope string
|
|
baseURL string
|
|
terminalBaseURL string
|
|
logger *slog.Logger
|
|
Output io.Writer
|
|
httpClient *http.Client
|
|
NoBrowser bool
|
|
IdentityEnricher func(context.Context, *TokenData) error
|
|
LoginRegion LoginRegion
|
|
}
|
|
|
|
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
|
|
return &DeviceFlowProvider{
|
|
configDir: configDir,
|
|
clientID: ClientID(),
|
|
scope: DefaultScopes,
|
|
baseURL: DefaultDeviceBaseURL,
|
|
terminalBaseURL: GetMCPBaseURL(),
|
|
logger: logger,
|
|
Output: os.Stderr,
|
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (p *DeviceFlowProvider) SetBaseURL(baseURL string) {
|
|
p.baseURL = strings.TrimRight(baseURL, "/")
|
|
}
|
|
|
|
func (p *DeviceFlowProvider) SetLoginRegion(region LoginRegion) {
|
|
p.LoginRegion = region
|
|
p.baseURL = DeviceBaseURLForLoginRegion(region)
|
|
p.terminalBaseURL = MCPBaseURLForLoginRegion(region)
|
|
}
|
|
|
|
// SetTerminalBaseURL sets the terminal API base URL for device flow polling.
|
|
func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) {
|
|
p.terminalBaseURL = strings.TrimRight(baseURL, "/")
|
|
}
|
|
|
|
// SetScope overrides the OAuth scope for the device flow.
|
|
func (p *DeviceFlowProvider) SetScope(scope string) {
|
|
if p != nil {
|
|
p.scope = scope
|
|
}
|
|
}
|
|
|
|
func (p *DeviceFlowProvider) output() io.Writer {
|
|
if p != nil && p.Output != nil {
|
|
return p.Output
|
|
}
|
|
return io.Discard
|
|
}
|
|
|
|
type DeviceAuthResponse struct {
|
|
DeviceCode string `json:"deviceCode"`
|
|
UserCode string `json:"userCode"`
|
|
VerificationURI string `json:"verificationUri"`
|
|
VerificationURIComplete string `json:"verificationUriComplete"`
|
|
ExpiresIn int `json:"expiresIn"`
|
|
Interval int `json:"interval"`
|
|
FlowID string `json:"flowId"`
|
|
}
|
|
|
|
type DeviceTokenResponse struct {
|
|
AuthCode string `json:"authCode"`
|
|
RedirectURL string `json:"redirectUrl"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// DevicePollResponse represents the response from the terminal API poll endpoint.
|
|
type DevicePollResponse struct {
|
|
Success bool `json:"success"`
|
|
Code string `json:"code,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
Data DevicePollData `json:"data"`
|
|
// Result is an alternate envelope some service versions return instead of
|
|
// (or alongside) Data. Always read poll fields via EffectiveData() rather
|
|
// than touching Data/Result directly.
|
|
Result DevicePollData `json:"result"`
|
|
}
|
|
|
|
type DevicePollData struct {
|
|
Status string `json:"status"`
|
|
AuthCode string `json:"authCode,omitempty"`
|
|
FlowID string `json:"flowId,omitempty"`
|
|
}
|
|
|
|
// EffectiveData normalizes terminal poll responses that may carry payload
|
|
// fields under either `data` or `result`.
|
|
//
|
|
// Semantics are envelope-level rather than field-level: when Data includes a
|
|
// non-empty status, treat Data as the authoritative payload and return it
|
|
// unchanged; otherwise fall back to Result. This avoids mixing fields from two
|
|
// disagreeing envelopes into a Frankenstein result.
|
|
func (r DevicePollResponse) EffectiveData() DevicePollData {
|
|
if r.Data.Status != "" || r.Result.Status == "" {
|
|
return r.Data
|
|
}
|
|
return r.Result
|
|
}
|
|
|
|
type serviceResult struct {
|
|
Success bool `json:"success"`
|
|
Result json.RawMessage `json:"result"`
|
|
ErrorCode string `json:"errorCode"`
|
|
ErrorMsg string `json:"errorMsg"`
|
|
}
|
|
|
|
// resetCredentialState clears any stale credential state inherited from
|
|
// previous login methods (OAuth, PAT, etc.) before device flow falls back to
|
|
// MCP-managed credentials.
|
|
//
|
|
// This is a defensive measure: no matter what a prior login wrote to
|
|
// app.json, device flow will re-fetch from MCP and set the correct
|
|
// clientIDFromMCP flag, ensuring exchangeCode() uses the MCP proxy path
|
|
// (which doesn't require clientSecret). Complete runtime AppKey/AppSecret
|
|
// overrides intentionally skip this reset.
|
|
func (p *DeviceFlowProvider) resetCredentialState() {
|
|
p.clientID = ""
|
|
clientMu.Lock()
|
|
clientIDFromMCP = false
|
|
clientMu.Unlock()
|
|
}
|
|
|
|
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
|
if err := prepareLoginPersistence(p.configDir); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
|
}
|
|
|
|
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 (OAuth scan, PAT, etc.) so we can re-fetch from MCP. This
|
|
// ensures --device login works regardless of what app.json contains.
|
|
p.resetCredentialState()
|
|
|
|
if p.logger != nil {
|
|
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
|
|
}
|
|
mcpClientID, mcpErr := deviceFetchClientIDForLoginRegion(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)
|
|
}
|
|
}
|
|
|
|
const maxAttempts = 3
|
|
for attempt := 1; ; attempt++ {
|
|
tokenData, err := deviceLoginOnce(p, ctx, attempt)
|
|
if err == nil {
|
|
return tokenData, nil
|
|
}
|
|
if isInvalidGrantError(err) && attempt < maxAttempts {
|
|
dfPrintWarning(p.output(), i18n.T("授权码已过期,正在重新发起设备授权流程..."))
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*TokenData, error) {
|
|
dfPrintStep(p.output(), 1, i18n.T("请求设备授权码..."), attempt)
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
|
|
authResp, err := deviceRequestCode(p, ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("请求设备授权码失败"), err)
|
|
}
|
|
dfPrintDeviceCodeBox(p.output(), authResp)
|
|
|
|
if authResp.VerificationURIComplete != "" && !p.NoBrowser {
|
|
if bErr := deviceOpenBrowser(authResp.VerificationURIComplete); bErr != nil && p.logger != nil {
|
|
p.logger.Debug("could not open browser", "error", bErr)
|
|
}
|
|
}
|
|
|
|
dfPrintStep(p.output(), 2, i18n.T("等待用户授权..."), 0)
|
|
dfPrintDim(p.output(), fmt.Sprintf(i18n.T(" (每 %d 秒轮询一次)"), authResp.Interval))
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
|
|
tokenResult, err := deviceWaitAuth(p, ctx, authResp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
dfPrintStep(p.output(), 3, i18n.T("使用授权码换取 Access Token..."), 0)
|
|
|
|
oauthProvider := &OAuthProvider{
|
|
configDir: p.configDir,
|
|
clientID: p.clientID,
|
|
logger: p.logger,
|
|
IdentityEnricher: p.IdentityEnricher,
|
|
LoginRegion: p.LoginRegion,
|
|
}
|
|
tokenData, err := deviceExchangeCode(oauthProvider, ctx, tokenResult.AuthCode)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
|
|
}
|
|
|
|
// Check if CLI auth is enabled for this organization (fail-closed: block on error)
|
|
dfPrintStep(p.output(), 4, i18n.T("检查组织 CLI 授权状态..."), 0)
|
|
authStatus, authErr := deviceCheckCLIAuth(oauthProvider, ctx, tokenData.AccessToken)
|
|
if authErr != nil {
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 无法检查 CLI 数据访问权限状态")))
|
|
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请检查网络连接后重试。"))
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("检查 CLI 授权状态失败"), authErr)
|
|
}
|
|
denialReason := classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL"))
|
|
if denialReason != "" {
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
switch denialReason {
|
|
case "user_forbidden":
|
|
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织已禁止所有成员使用 CLI")))
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
return nil, errors.New(i18n.T("该组织已禁止所有成员使用 CLI"))
|
|
case "user_not_allowed":
|
|
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 您不在该组织的 CLI 授权人员范围内")))
|
|
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织管理员将您加入 CLI 授权人员名单。"))
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员"))
|
|
case "channel_not_allowed":
|
|
ch := os.Getenv("DWS_CHANNEL")
|
|
_, _ = fmt.Fprintf(p.output(), dfRed(i18n.T("⚠️ 当前渠道 %s 未获得该组织授权"))+"\n", ch)
|
|
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织管理员开通该渠道的访问权限。"))
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
return nil, fmt.Errorf(i18n.T("当前渠道 %s 未获得该组织授权,请联系组织管理员"), ch)
|
|
case "channel_required":
|
|
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 当前组织已开启渠道管控")))
|
|
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请升级到最新版本的 CLI 后重试。"))
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
return nil, errors.New(i18n.T("当前组织已开启渠道管控,请升级到最新版本的 CLI 后重试"))
|
|
case "enterprise_not_authorized":
|
|
msg := i18n.T("本次请求未通过企业安全认证")
|
|
if authStatus != nil && strings.TrimSpace(authStatus.ErrorMsg) != "" {
|
|
msg = strings.TrimSpace(authStatus.ErrorMsg)
|
|
}
|
|
_, _ = fmt.Fprintln(p.output(), dfRed("⚠️ "+msg))
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
return nil, errors.New(msg)
|
|
case "no_auth":
|
|
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 认证已失效")))
|
|
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请执行 dws auth 重新登录。"))
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
return nil, errors.New(i18n.T("认证已失效,请执行 dws auth 重新登录"))
|
|
default:
|
|
// cli_not_enabled or unknown — show existing admin-apply flow
|
|
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限")))
|
|
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
|
|
admins, adminErr := deviceGetAdminsForLoginRegion(ctx, tokenData.AccessToken, p.LoginRegion)
|
|
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
|
|
maxAdmins := 3
|
|
if len(admins.Result) < maxAdmins {
|
|
maxAdmins = len(admins.Result)
|
|
}
|
|
var adminNames []string
|
|
for i := 0; i < maxAdmins; i++ {
|
|
adminNames = append(adminNames, admins.Result[i].Name)
|
|
}
|
|
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、"))
|
|
}
|
|
|
|
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。"))
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("管理员操作入口:"), config.GetDeveloperSettingsURL())
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启"))
|
|
}
|
|
}
|
|
|
|
// Save token data with associated client ID for refresh
|
|
tokenData.ClientID = p.clientID
|
|
if err := oauthProvider.prepareLoginToken(ctx, tokenData); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
|
}
|
|
if err := preflightTokenWritePersistence(p.configDir, tokenData); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
|
}
|
|
if err := deviceSaveToken(p.configDir, tokenData); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
|
}
|
|
|
|
// Persist app credentials (with secret) if using custom client credentials.
|
|
// MUST run BEFORE os.Setenv below to avoid env-matching short circuit.
|
|
oauthProvider.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 !deviceHasAppConfig(p.configDir) {
|
|
_ = deviceSaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
|
|
}
|
|
}
|
|
|
|
return tokenData, nil
|
|
}
|
|
|
|
func (p *DeviceFlowProvider) requestDeviceCode(ctx context.Context) (*DeviceAuthResponse, error) {
|
|
params := url.Values{"client_id": {p.clientID}}
|
|
if p.scope != "" {
|
|
params.Set("scope", p.scope)
|
|
}
|
|
endpoint := p.baseURL + DeviceCodePath
|
|
body, err := p.postForm(ctx, endpoint, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var sr serviceResult
|
|
if err := json.Unmarshal(body, &sr); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("解析响应失败"), err)
|
|
}
|
|
if !sr.Success {
|
|
return nil, fmt.Errorf("%s: [%s] %s", i18n.T("服务端返回错误"), sr.ErrorCode, sr.ErrorMsg)
|
|
}
|
|
|
|
var resp DeviceAuthResponse
|
|
if err := json.Unmarshal(sr.Result, &resp); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("解析设备授权数据失败"), err)
|
|
}
|
|
if resp.DeviceCode == "" || resp.UserCode == "" {
|
|
return nil, errors.New(i18n.T("服务端返回了空的 device_code 或 user_code"))
|
|
}
|
|
if resp.Interval <= 0 || resp.Interval > maxPollInterval {
|
|
resp.Interval = defaultPollInterval
|
|
}
|
|
if resp.ExpiresIn <= 0 {
|
|
resp.ExpiresIn = 900
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
func (p *DeviceFlowProvider) pollDeviceToken(ctx context.Context, deviceCode string) (*DeviceTokenResponse, error) {
|
|
params := url.Values{
|
|
"grant_type": {DeviceGrantType},
|
|
"device_code": {deviceCode},
|
|
"client_id": {p.clientID},
|
|
}
|
|
endpoint := p.baseURL + DeviceTokenPath
|
|
body, err := p.postForm(ctx, endpoint, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var sr serviceResult
|
|
if err := json.Unmarshal(body, &sr); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("解析响应失败"), err)
|
|
}
|
|
if !sr.Success {
|
|
return nil, fmt.Errorf("%s: %s %s", i18n.T("服务端返回错误"), sr.ErrorCode, sr.ErrorMsg)
|
|
}
|
|
|
|
var resp DeviceTokenResponse
|
|
if err := json.Unmarshal(sr.Result, &resp); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("解析令牌数据失败"), err)
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
// pollDeviceStatus polls the terminal API for device authorization status.
|
|
//
|
|
// Note: The server returns success=false for REJECTED and EXPIRED terminal
|
|
// states (with a valid data.Status value). These are normal business outcomes,
|
|
// not transport errors, so we return the response to the caller and let the
|
|
// status-switch handle them.
|
|
func (p *DeviceFlowProvider) pollDeviceStatus(ctx context.Context, flowID string) (*DevicePollResponse, error) {
|
|
endpoint := fmt.Sprintf("%s%s?flowId=%s", p.terminalBaseURL, DevicePollPath, url.QueryEscape(flowID))
|
|
body, err := p.doGet(ctx, endpoint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp DevicePollResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("解析响应失败"), err)
|
|
}
|
|
// REJECTED/EXPIRED carry success=false but have a valid data.Status;
|
|
// only treat as a real server error when data.Status is empty.
|
|
if !resp.Success && resp.Data.Status == "" {
|
|
return nil, fmt.Errorf("%s: [%s] %s", i18n.T("服务端返回错误"), resp.Code, resp.Message)
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
func (p *DeviceFlowProvider) waitForAuthorization(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
|
|
if auth.FlowID == "" {
|
|
// Keep the pre-flowId device-code polling path for regular device flow
|
|
// login responses that do not include terminal polling metadata.
|
|
return p.waitForAuthorizationByDeviceCode(ctx, auth)
|
|
}
|
|
return p.waitForAuthorizationByFlowID(ctx, auth)
|
|
}
|
|
|
|
func (p *DeviceFlowProvider) waitForAuthorizationByFlowID(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
|
|
startTime := time.Now()
|
|
interval := time.Duration(auth.Interval) * time.Second
|
|
deadline := time.Duration(auth.ExpiresIn) * time.Second
|
|
pollCount := 0
|
|
|
|
for {
|
|
elapsed := time.Since(startTime)
|
|
if elapsed >= maxPollTotalWait || elapsed >= deadline {
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
return nil, fmt.Errorf("%s", i18n.Tf("设备授权码已过期(%d 秒),请重试", auth.ExpiresIn))
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-deviceFlowAfter(interval):
|
|
}
|
|
|
|
pollCount++
|
|
elapsedSec := int(time.Since(startTime).Seconds())
|
|
dfPrintPollStatus(p.output(), pollCount, elapsedSec)
|
|
|
|
pollResp, err := devicePollStatus(p, ctx, auth.FlowID)
|
|
if err != nil {
|
|
dfPrintPollResult(p.output(), "network_error", i18n.T("网络错误,继续重试..."))
|
|
if p.logger != nil {
|
|
p.logger.Debug("poll error", "error", err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
pollData := pollResp.EffectiveData()
|
|
switch pollData.Status {
|
|
case StatusApproved:
|
|
dfPrintPollResult(p.output(), "authorized", i18n.T("授权成功!"))
|
|
return &DeviceTokenResponse{AuthCode: pollData.AuthCode}, nil
|
|
case StatusPending:
|
|
dfPrintPollResult(p.output(), "pending", i18n.T("等待用户授权..."))
|
|
case StatusRejected:
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
return nil, errors.New(i18n.T("用户拒绝了授权请求"))
|
|
case StatusExpired:
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
return nil, errors.New(i18n.T("设备授权码已过期"))
|
|
default:
|
|
dfPrintPollResult(p.output(), "unknown", fmt.Sprintf(i18n.T("未知状态: %s"), pollData.Status))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *DeviceFlowProvider) waitForAuthorizationByDeviceCode(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
|
|
startTime := time.Now()
|
|
interval := time.Duration(auth.Interval) * time.Second
|
|
deadline := time.Duration(auth.ExpiresIn) * time.Second
|
|
pollCount := 0
|
|
|
|
for {
|
|
elapsed := time.Since(startTime)
|
|
if elapsed >= maxPollTotalWait || elapsed >= deadline {
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
return nil, fmt.Errorf("%s", i18n.Tf("设备授权码已过期(%d 秒),请重试", auth.ExpiresIn))
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-deviceFlowAfter(interval):
|
|
}
|
|
|
|
pollCount++
|
|
elapsedSec := int(time.Since(startTime).Seconds())
|
|
dfPrintPollStatus(p.output(), pollCount, elapsedSec)
|
|
|
|
resp, err := devicePollToken(p, ctx, auth.DeviceCode)
|
|
if err != nil {
|
|
dfPrintPollResult(p.output(), "network_error", i18n.T("网络错误,继续重试..."))
|
|
if p.logger != nil {
|
|
p.logger.Debug("poll error", "error", err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if resp.Error == "" {
|
|
dfPrintPollResult(p.output(), "authorized", i18n.T("授权成功!"))
|
|
return resp, nil
|
|
}
|
|
switch resp.Error {
|
|
case "authorization_pending":
|
|
dfPrintPollResult(p.output(), "pending", i18n.T("等待用户授权..."))
|
|
case "slow_down":
|
|
interval += 5 * time.Second
|
|
if interval > maxPollInterval*time.Second {
|
|
interval = maxPollInterval * time.Second
|
|
}
|
|
dfPrintPollResult(p.output(), "slow_down", fmt.Sprintf(i18n.T("轮询过快,间隔增加至 %ds"), int(interval.Seconds())))
|
|
case "access_denied":
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
return nil, errors.New(i18n.T("用户拒绝了授权请求"))
|
|
case "expired_token":
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
return nil, errors.New(i18n.T("设备授权码已过期"))
|
|
default:
|
|
dfPrintPollResult(p.output(), "unknown", fmt.Sprintf(i18n.T("未知错误: %s"), resp.Error))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *DeviceFlowProvider) postForm(ctx context.Context, endpoint string, params url.Values) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(params.Encode()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("创建请求失败"), err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("发送请求失败"), err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("读取响应失败"), err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBody(body, 200))
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// doGet performs an HTTP GET request and returns the response body.
|
|
func (p *DeviceFlowProvider) doGet(ctx context.Context, endpoint string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("创建请求失败"), err)
|
|
}
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("发送请求失败"), err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("读取响应失败"), err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBody(body, 200))
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// truncateBody returns a string of at most maxLen bytes from body, appending
|
|
// "...(truncated)" when the content exceeds the limit. This prevents leaking
|
|
// potentially sensitive response payloads in error messages.
|
|
func truncateBody(body []byte, maxLen int) string {
|
|
if len(body) <= maxLen {
|
|
return string(body)
|
|
}
|
|
return string(body[:maxLen]) + "...(truncated)"
|
|
}
|
|
|
|
var (
|
|
dfBold = tui.Bold
|
|
dfGreen = tui.Success
|
|
dfYellow = tui.Warning
|
|
dfRed = tui.Danger
|
|
dfCyan = tui.Cyan
|
|
dfDim = tui.Dim
|
|
)
|
|
|
|
func dfPrintStep(w io.Writer, step int, message string, attempt int) {
|
|
label := fmt.Sprintf("Step %d", step)
|
|
if attempt > 1 {
|
|
_, _ = fmt.Fprintf(w, i18n.T("%s %s: %s (第 %d 次尝试)\\n"), tui.StateMark("ok"), dfBold(label), message, attempt)
|
|
return
|
|
}
|
|
_, _ = fmt.Fprintf(w, "%s %s: %s\n", tui.StateMark("ok"), dfBold(label), message)
|
|
}
|
|
|
|
func dfPrintDeviceCodeBox(w io.Writer, auth *DeviceAuthResponse) {
|
|
lines := []string{
|
|
i18n.T("请在浏览器中打开以下链接,并输入授权码:"),
|
|
"",
|
|
fmt.Sprintf(i18n.T(" 链接: %s"), dfBold(auth.VerificationURI)),
|
|
fmt.Sprintf(i18n.T(" 授权码: %s"), dfBold(dfYellow(auth.UserCode))),
|
|
"",
|
|
}
|
|
if auth.VerificationURIComplete != "" {
|
|
lines = append(lines,
|
|
i18n.T("或者直接打开以下链接:"),
|
|
fmt.Sprintf(" %s", dfCyan(auth.VerificationURIComplete)),
|
|
"",
|
|
)
|
|
}
|
|
lines = append(lines, dfDim(fmt.Sprintf(i18n.T("授权码将在 %d 秒后过期。"), auth.ExpiresIn)))
|
|
dfPrintBox(w, lines)
|
|
_, _ = fmt.Fprintln(w, "")
|
|
}
|
|
|
|
func dfPrintBox(w io.Writer, lines []string) {
|
|
maxLen := 0
|
|
for _, line := range lines {
|
|
if l := tui.PlainRuneWidth(line); l > maxLen {
|
|
maxLen = l
|
|
}
|
|
}
|
|
if maxLen < 50 {
|
|
maxLen = 50
|
|
}
|
|
|
|
border := strings.Repeat("─", maxLen+4)
|
|
_, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╭"+border+"╮"))
|
|
for _, line := range lines {
|
|
pad := maxLen - tui.PlainRuneWidth(line)
|
|
_, _ = fmt.Fprintf(w, " %s %s%s %s\n", tui.Blue("│"), line, strings.Repeat(" ", pad), tui.Blue("│"))
|
|
}
|
|
_, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╰"+border+"╯"))
|
|
}
|
|
|
|
func dfPrintPollStatus(w io.Writer, count, elapsedSec int) {
|
|
_, _ = fmt.Fprintf(w, " %s %s ", tui.StateMark("pending"), dfDim(fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), count, elapsedSec)))
|
|
}
|
|
|
|
func dfPrintPollResult(w io.Writer, status, message string) {
|
|
switch status {
|
|
case "authorized":
|
|
_, _ = fmt.Fprintln(w, dfGreen(message))
|
|
case "pending", "slow_down":
|
|
_, _ = fmt.Fprintln(w, dfYellow(message))
|
|
default:
|
|
_, _ = fmt.Fprintln(w, dfRed(message))
|
|
}
|
|
}
|
|
|
|
func dfPrintWarning(w io.Writer, message string) {
|
|
_, _ = fmt.Fprintln(w, dfYellow(message))
|
|
}
|
|
|
|
func dfPrintDim(w io.Writer, message string) {
|
|
_, _ = fmt.Fprintln(w, dfDim(message))
|
|
}
|
|
|
|
func isInvalidGrantError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := strings.ToLower(err.Error() + " " + httpStatusResponseBody(err))
|
|
return strings.Contains(msg, "invalid_grant") || (strings.Contains(msg, "code") && strings.Contains(msg, "expired"))
|
|
}
|