From 403b4c92e3edaadec54969c3156c203b2db85ff6 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Sun, 20 Sep 2026 17:32:28 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20CLI=20?= =?UTF-8?q?=E8=B7=A8=E8=AE=BE=E5=A4=87=E7=BD=91=E9=A1=B5=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E4=B8=8E=E5=AE=89=E5=85=A8=E9=A2=86=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- cmd/auth/auth.go | 4 +- internal/auth/manager.go | 16 +- internal/auth/session_client.go | 220 +++++++++++++++++++++++++ internal/auth/session_login.go | 283 ++++++++++++++++++++++++++++++++ internal/auth/types.go | 13 +- 5 files changed, 532 insertions(+), 4 deletions(-) create mode 100644 internal/auth/session_client.go create mode 100644 internal/auth/session_login.go diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 5d6883f..399be5f 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -30,6 +30,7 @@ type logoutResult struct { // NewLoginCommand creates the top-level `pippit-tool-cli login` command. func NewLoginCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { var forceRefresh bool + var legacyLoopback bool command := &cobra.Command{ Use: "login", Short: "通过浏览器登录小云雀 CLI", @@ -40,7 +41,7 @@ func NewLoginCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Com return err } credential, err := manager.Login(command.Context(), internal_auth.LoginOptions{ - Progress: stderr, ForceRefresh: forceRefresh, + Progress: stderr, ForceRefresh: forceRefresh, LegacyLoopback: legacyLoopback, }) if err != nil { return err @@ -60,6 +61,7 @@ func NewLoginCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Com command.SetOut(stdout) command.SetErr(stderr) command.Flags().BoolVar(&forceRefresh, "force", false, "强制轮换当前设备的 CLI Access Key(仅在旧密钥被拒绝时使用)") + command.Flags().BoolVar(&legacyLoopback, "legacy-loopback", false, "使用旧版同机浏览器回调登录(不支持跨设备)") return command } diff --git a/internal/auth/manager.go b/internal/auth/manager.go index 458091c..07cbb80 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "net/http" "net/url" "strings" "sync" @@ -20,6 +21,9 @@ type Manager struct { authBaseURL *url.URL random io.Reader now func() time.Time + authClient *http.Client + wait func(context.Context, time.Duration) error + jitter func() time.Duration credentialMu sync.Mutex cachedCredential *Credential credentialCacheLoaded bool @@ -46,6 +50,9 @@ func NewManager(cfg *config.Config, options ...ManagerOption) *Manager { authBaseURL: authBaseURL, random: rand.Reader, now: time.Now, + authClient: &http.Client{Timeout: 15 * time.Second}, + wait: waitForAuthorization, + jitter: authorizationJitter, } for _, option := range options { if option != nil { @@ -75,6 +82,13 @@ func (m *Manager) ResolveAccessKey(ctx context.Context) (string, error) { } func (m *Manager) Login(ctx context.Context, options LoginOptions) (*Credential, error) { + if options.LegacyLoopback { + return m.loginLoopback(ctx, options) + } + return m.loginSession(ctx, options) +} + +func (m *Manager) loginLoopback(ctx context.Context, options LoginOptions) (*Credential, error) { if err := m.validate(); err != nil { return nil, err } @@ -308,7 +322,7 @@ func (m *Manager) validate() error { if m.authBaseURL.Scheme != "https" && !(m.authBaseURL.Scheme == "http" && isLoopbackHost(m.authBaseURL.Hostname())) { return errors.New("小云雀授权地址必须使用 HTTPS") } - if m.authBaseURL.Host == "" || m.authBaseURL.RawQuery != "" || m.authBaseURL.Fragment != "" { + if m.authBaseURL.Host == "" || m.authBaseURL.User != nil || m.authBaseURL.Opaque != "" || m.authBaseURL.RawQuery != "" || m.authBaseURL.Fragment != "" { return errors.New("小云雀授权地址无效") } return nil diff --git a/internal/auth/session_client.go b/internal/auth/session_client.go new file mode 100644 index 0000000..a97b570 --- /dev/null +++ b/internal/auth/session_client.go @@ -0,0 +1,220 @@ +package auth + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + sessionAPIPath = "/api/web/v1/cli/auth/session/" + authorizePagePath = "/cli/pippit-tool-authorize" + minimumPollInterval = 5 * time.Second + claimWindow = 5 * time.Minute + maxSessionResponseBytes = 64 << 10 +) + +type authSession struct { + ID string `json:"session_id"` + UserCode string `json:"user_code"` + Status string `json:"status"` + Reason string `json:"reason"` + ExpiresAt int64 `json:"expires_at"` + ClaimExpiresAt int64 `json:"claim_expires_at"` + Interval int64 `json:"interval"` + ExpectedAccount string `json:"expected_account"` + Force bool `json:"force"` + TeamID string `json:"team_id"` +} + +type sessionCredential struct { + AccessKey string `json:"ak"` + TokenID string `json:"token_id"` + UID string `json:"uid"` + ExpiredAt int64 `json:"expired_at"` + TeamID string `json:"team_id"` +} + +type sessionResponse struct { + Session authSession `json:"session"` + ClaimSecret string `json:"claim_secret"` + VerificationURI string `json:"verification_uri"` + Credential *sessionCredential `json:"credential"` +} + +type createSessionRequest struct { + DeviceID string `json:"device_id"` + ExpectedAccount string `json:"expected_account,omitempty"` + TokenID string `json:"token_id,omitempty"` + Force bool `json:"force,omitempty"` +} + +type sessionRequest struct { + SessionID string `json:"session_id"` + TokenID string `json:"token_id,omitempty"` + Result string `json:"result,omitempty"` +} + +type sessionRequestError struct { + retryable bool + slowDown bool + timeout bool + retryAfter time.Duration +} + +func (e *sessionRequestError) Error() string { + if e.timeout { + return "授权服务请求超时" + } + if e.slowDown { + return "授权请求过于频繁,请稍后重试" + } + if e.retryable { + return "授权服务暂不可用,请稍后重试" + } + return "授权请求失败,请重新登录" +} + +// The client uses the canonical authentication origin, independently of the +// business API base URL. No redirect may carry a claim secret to another URL. +func (m *Manager) callSession(ctx context.Context, action, secret string, input any) (*sessionResponse, error) { + payload, err := json.Marshal(input) + if err != nil { + return nil, errors.New("授权请求格式无效") + } + endpoint := *m.authBaseURL + endpoint.Path, endpoint.RawPath = sessionAPIPath+action, "" + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) + if err != nil { + return nil, errors.New("授权请求地址无效") + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + if secret != "" { + request.Header.Set("X-Cli-Session-Secret", secret) + } + client := *m.authClient + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + client.Jar = nil + response, err := client.Do(request) + if err != nil { + return nil, sessionTransportError(ctx, err) + } + defer response.Body.Close() + if action == "create" && (response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusMethodNotAllowed) { + return nil, ErrSessionUnsupported + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + timedOut := response.StatusCode == http.StatusRequestTimeout || response.StatusCode == http.StatusGatewayTimeout + return nil, &sessionRequestError{ + retryable: timedOut || response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500, + slowDown: response.StatusCode == http.StatusTooManyRequests, + timeout: timedOut, + retryAfter: retryAfterDelay(response.Header.Get("Retry-After"), m.now()), + } + } + body, err := io.ReadAll(io.LimitReader(response.Body, maxSessionResponseBytes+1)) + if err != nil { + return nil, sessionTransportError(ctx, err) + } + if len(body) > maxSessionResponseBytes { + return nil, errors.New("授权服务返回内容过大") + } + var envelope struct { + Ret string `json:"ret"` + Data *sessionResponse `json:"data"` + } + if json.Unmarshal(body, &envelope) != nil { + return nil, errors.New("授权服务响应格式无效") + } + // Values come from capcut_business_common_code.thrift. Never classify + // errors by untrusted errmsg text or copy it into terminal output. + if envelope.Ret != "0" { + return nil, &sessionRequestError{ + retryable: envelope.Ret == "4" || envelope.Ret == "6" || envelope.Ret == "10" || envelope.Ret == "20002", + slowDown: envelope.Ret == "10", timeout: envelope.Ret == "6", + retryAfter: retryAfterDelay(response.Header.Get("Retry-After"), m.now()), + } + } + if envelope.Data == nil { + return nil, errors.New("授权服务响应缺少会话") + } + return envelope.Data, nil +} + +func sessionTransportError(ctx context.Context, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + var timeout interface{ Timeout() bool } + return &sessionRequestError{retryable: true, timeout: errors.As(err, &timeout) && timeout.Timeout()} +} + +func retryAfterDelay(value string, now time.Time) time.Duration { + if seconds, err := strconv.ParseInt(value, 10, 64); err == nil && seconds > 0 { + return time.Duration(min(seconds, 3600)) * time.Second + } + if deadline, err := http.ParseTime(value); err == nil && deadline.After(now) { + return min(deadline.Sub(now), time.Hour) + } + return 0 +} + +func validSessionSecret(value string) bool { + decoded, err := base64.RawURLEncoding.DecodeString(value) + return err == nil && len(decoded) == 32 && len(value) == 43 && + constantTimeEqual(value, base64.RawURLEncoding.EncodeToString(decoded)) +} + +func personalSpace(teamID string) bool { return teamID == "" || teamID == "0" } + +func validateSession(session authSession, request createSessionRequest, sessionID string) error { + if !validSessionSecret(session.ID) || (sessionID != "" && !constantTimeEqual(sessionID, session.ID)) || + !personalSpace(session.TeamID) || session.Force != request.Force || + !constantTimeEqual(session.ExpectedAccount, request.ExpectedAccount) || + session.ExpiresAt <= 0 || session.Interval < 0 || session.Interval > 3600 { + return errors.New("授权会话绑定无效") + } + if len(session.UserCode) != 9 || session.UserCode[4] != '-' { + return errors.New("授权设备码无效") + } + for i, c := range session.UserCode { + if i != 4 && !strings.ContainsRune("ABCDEFGHJKLMNPQRSTUVWXYZ23456789", c) { + return errors.New("授权设备码无效") + } + } + switch session.Status { + case "pending", "denied", "expired": + case "authorized", "completed": + if session.ClaimExpiresAt <= 0 || session.ClaimExpiresAt > session.ExpiresAt+int64(claimWindow/time.Second) { + return errors.New("授权领取期限无效") + } + default: + return errors.New("授权会话状态无效") + } + return nil +} + +func verificationURL(base *url.URL, rawURI, sessionID string) (string, error) { + reference, err := url.Parse(rawURI) + if err != nil { + return "", errors.New("网页授权地址无效") + } + target := base.ResolveReference(reference) + query, queryErr := url.ParseQuery(target.RawQuery) + if queryErr != nil || target.User != nil || target.Opaque != "" || target.Fragment != "" || + target.Scheme != base.Scheme || !strings.EqualFold(target.Host, base.Host) || + target.Path != authorizePagePath || target.RawPath != "" || len(query) != 1 || + len(query["session_id"]) != 1 || !constantTimeEqual(query.Get("session_id"), sessionID) { + return "", errors.New("网页授权地址不可信") + } + return target.String(), nil +} diff --git a/internal/auth/session_login.go b/internal/auth/session_login.go new file mode 100644 index 0000000..f5dc2b9 --- /dev/null +++ b/internal/auth/session_login.go @@ -0,0 +1,283 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "strings" + "time" +) + +func (m *Manager) loginSession(ctx context.Context, options LoginOptions) (*Credential, error) { + if err := m.validate(); err != nil { + return nil, err + } + if m.authClient == nil || m.wait == nil || m.jitter == nil { + return nil, errors.New("授权客户端未正确初始化") + } + timeout := options.Timeout + if timeout <= 0 { + timeout = DefaultSessionLoginTimeout + } + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + credential, err := m.runSessionLogin(waitCtx, options) + if errors.Is(err, context.DeadlineExceeded) { + return nil, ErrLoginWaitTimeout + } + return credential, err +} + +func (m *Manager) runSessionLogin(ctx context.Context, options LoginOptions) (*Credential, error) { + identity, err := m.ensureIdentity(ctx) + if err != nil { + return nil, err + } + request := createSessionRequest{DeviceID: identity.DeviceID, TokenID: identity.TokenID, + Force: options.ForceRefresh && strings.TrimSpace(identity.TokenID) != ""} + if identity.UID != "" { + request.ExpectedAccount = accountBinding(identity.UID) + } + if expected := strings.TrimSpace(options.ExpectedCredentialScope); expected != "" { + binding, ok := accountBindingFromCredentialScope(expected, identity.DeviceID) + if !ok || (request.ExpectedAccount != "" && !constantTimeEqual(request.ExpectedAccount, binding)) { + return nil, ErrCredentialAccountMismatch + } + request.ExpectedAccount = binding + } + // Logout preserves the device and a non-secret token selector but clears + // the account. Without an account binding, ordinary login must let the + // confirmed user's device name select the token instead of sending a stale + // selector from another account. Force still requires the original binding. + if request.ExpectedAccount == "" && !request.Force { + request.TokenID = "" + } + if !validDeviceID(request.DeviceID) || (request.TokenID != "" && !validTokenID(request.TokenID)) || + (request.ExpectedAccount != "" && !validAccountBinding(request.ExpectedAccount)) || + (request.Force && request.ExpectedAccount == "") { + return nil, errors.New("本机登录设备或账号绑定无效") + } + // Creation is not automatically retried: a lost response must not create + // multiple pending sessions for one login. The user can start a fresh login. + created, err := m.callSession(ctx, "create", "", request) + if err != nil { + return nil, err + } + if err := validateSession(created.Session, request, ""); err != nil { + return nil, err + } + if created.Session.Status != "pending" || !validSessionSecret(created.ClaimSecret) || created.Credential != nil || + created.Session.ExpiresAt <= m.now().Unix() || created.Session.ExpiresAt > m.now().Add(15*time.Minute+time.Minute).Unix() { + return nil, errors.New("新建授权会话无效") + } + loginURL, err := verificationURL(m.authBaseURL, created.VerificationURI, created.Session.ID) + if err != nil { + return nil, err + } + writeProgress(options.Progress, "小云雀网页授权地址(如未自动打开,可复制到任意设备的浏览器):") + writeProgress(options.Progress, loginURL) + writeProgress(options.Progress, "请核对网页上的设备码:"+created.Session.UserCode) + opener := options.OpenURL + if opener == nil { + opener = OpenBrowser + } + if opener(loginURL) != nil { + writeProgress(options.Progress, "未能自动打开浏览器,请复制上方地址继续;CLI 将继续等待授权。") + } + writeProgress(options.Progress, "请在浏览器中登录个人空间并确认授权,CLI 会自动继续…") + // A pending response near the authorization deadline may race with approval. + // Keep polling through the possible claim window; only the server can report + // whether authorization expired or was approved just before its deadline. + serverDeadline := time.Unix(created.Session.ExpiresAt, 0).Add(claimWindow) + interval := max(minimumPollInterval, time.Duration(created.Session.Interval)*time.Second) + delay := interval + for { + if err := m.waitSession(ctx, delay, serverDeadline); err != nil { + return nil, err + } + result, pollErr := m.callSession(ctx, "poll", created.ClaimSecret, sessionRequest{SessionID: created.Session.ID}) + if pollErr != nil { + var requestErr *sessionRequestError + if !errors.As(pollErr, &requestErr) || !requestErr.retryable { + return nil, pollErr + } + delay, interval = nextPollDelay(delay, interval, requestErr) + continue + } + if err := validateSession(result.Session, request, created.Session.ID); err != nil { + return nil, err + } + if result.Session.ExpiresAt != created.Session.ExpiresAt || result.Session.UserCode != created.Session.UserCode { + return nil, errors.New("授权会话在等待中发生变化") + } + interval = max(interval, time.Duration(result.Session.Interval)*time.Second) + delay = interval + switch result.Session.Status { + case "pending": + if result.Credential != nil { + return nil, errors.New("未确认授权返回了凭据") + } + continue + case "denied": + if result.Session.Reason == "policy_denied" { + return nil, ErrAuthorizationPolicy + } + return nil, ErrAuthorizationDenied + case "expired": + return nil, sessionExpiredError(result.Session.Reason) + case "authorized", "completed": + if result.Session.ClaimExpiresAt <= m.now().Unix() { + return nil, sessionExpiredError("delivery_unconfirmed") + } + credential, err := m.validateSessionCredential(identity, request, options, result.Credential) + if err != nil { + return nil, err + } + writeProgress(options.Progress, "网页授权已完成,正在安全保存本机 CLI 凭证…") + if err := m.saveSessionCredential(ctx, created, result.Session, credential); err != nil { + return nil, err + } + writeProgress(options.Progress, "小云雀 CLI 登录成功。") + return cloneCredential(credential), nil + } + } +} + +func (m *Manager) validateSessionCredential(identity *Credential, request createSessionRequest, options LoginOptions, payload *sessionCredential) (*Credential, error) { + if payload == nil || payload.AccessKey == "" || !personalSpace(payload.TeamID) { + return nil, errors.New("网页返回的 CLI 凭据无效或不属于个人空间") + } + credential := credentialFromCallback(identity, accessKeyPayload{AccessKey: payload.AccessKey, + TokenID: payload.TokenID, UID: payload.UID, ExpiredAt: payload.ExpiredAt}) + if err := validateCredential(credential); err != nil { + return nil, errors.New("网页返回的 CLI 凭证格式无效") + } + if credential.ExpiredAt <= m.now().Add(m.ensureTTL()).Unix() { + return nil, ErrCredentialExpired + } + if request.ExpectedAccount != "" && !constantTimeEqual(request.ExpectedAccount, accountBinding(credential.UID)) { + return nil, ErrCredentialAccountMismatch + } + if expected := strings.TrimSpace(options.ExpectedCredentialScope); expected != "" && !constantTimeEqual(expected, credential.CredentialScope) { + return nil, ErrCredentialAccountMismatch + } + if request.Force && ((identity.AccessKey != "" && constantTimeEqual(identity.AccessKey, credential.AccessKey)) || + constantTimeEqual(identity.TokenID, credential.TokenID)) { + return nil, errors.New("网页授权未轮换已失效的 CLI Access Key,已拒绝继续使用旧凭证") + } + return credential, nil +} + +func (m *Manager) saveSessionCredential(ctx context.Context, created *sessionResponse, session authSession, credential *Credential) error { + deadline := time.Unix(session.ClaimExpiresAt, 0) + delay := time.Second + reportedFailure := false + for { + if credential.ExpiredAt <= m.now().Add(m.ensureTTL()).Unix() { + return ErrCredentialExpired + } + err := m.saveCredential(ctx, credential) + if err == nil { + // Save is the commit point. ACK has a separate, short retry budget and + // must never undo a successful save, even if the caller is cancelled. + m.ackSession(ctx, created, credential.TokenID, "saved") + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + if !reportedFailure { + m.ackSession(ctx, created, credential.TokenID, "save_failed") + reportedFailure = true + } + if waitErr := m.waitSession(ctx, delay, deadline); waitErr != nil { + return fmt.Errorf("保存 CLI 凭据失败,授权领取窗口或等待时间已结束: %w", err) + } + delay = min(delay*2, 30*time.Second) + } +} + +func (m *Manager) ackSession(ctx context.Context, created *sessionResponse, tokenID, result string) { + ackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + for attempt := 0; attempt < 2; attempt++ { + _, err := m.callSession(ackCtx, "ack", created.ClaimSecret, sessionRequest{ + SessionID: created.Session.ID, TokenID: tokenID, Result: result, + }) + if err == nil { + return + } + var requestErr *sessionRequestError + if !errors.As(err, &requestErr) || !requestErr.retryable { + return + } + // A limiter response is not retried within this short ACK budget. + if requestErr.slowDown || requestErr.retryAfter > 0 { + return + } + if attempt == 0 && m.wait(ackCtx, 250*time.Millisecond) != nil { + return + } + } +} + +func (m *Manager) waitSession(ctx context.Context, delay time.Duration, deadline time.Time) error { + remaining := deadline.Sub(m.now()) + if remaining <= 0 { + return ErrLoginWaitTimeout + } + delay += max(0, m.jitter()) + if delay >= remaining { + if err := m.wait(ctx, remaining); err != nil { + return err + } + return ErrLoginWaitTimeout + } + return m.wait(ctx, delay) +} + +func nextPollDelay(delay, interval time.Duration, requestErr *sessionRequestError) (time.Duration, time.Duration) { + if requestErr.slowDown { + interval = max(interval, minimumPollInterval) + minimumPollInterval + delay = max(delay, interval) + } else { + delay = max(interval, min(delay*2, time.Minute)) + } + return max(delay, requestErr.retryAfter), interval +} + +func sessionExpiredError(reason string) error { + switch reason { + case "no_observed_action": + return fmt.Errorf("%w(服务端未观察到打开授权页面)", ErrAuthorizationExpired) + case "awaiting_decision": + return fmt.Errorf("%w(未在有效期内确认)", ErrAuthorizationExpired) + case "delivery_unconfirmed": + return fmt.Errorf("%w(凭据领取或保存结果未确认)", ErrAuthorizationExpired) + case "client_save_failed": + return fmt.Errorf("%w(CLI 曾报告本机保存失败)", ErrAuthorizationExpired) + default: + return ErrAuthorizationExpired + } +} + +func waitForAuthorization(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func authorizationJitter() time.Duration { + var value [2]byte + if _, err := rand.Read(value[:]); err != nil { + return 0 + } + return time.Duration(binary.BigEndian.Uint16(value[:])%1000) * time.Millisecond +} diff --git a/internal/auth/types.go b/internal/auth/types.go index 1e7351a..c7ffc15 100644 --- a/internal/auth/types.go +++ b/internal/auth/types.go @@ -15,7 +15,8 @@ const ( deviceIDBytes = 32 randomBindingBytes = 32 - DefaultLoginTimeout = 5 * time.Minute + DefaultLoginTimeout = 5 * time.Minute + DefaultSessionLoginTimeout = 20 * time.Minute ) var ( @@ -24,6 +25,11 @@ var ( ErrSecureStore = errors.New("安全凭证存储不可用") ErrCredentialAccountMismatch = errors.New("网页授权账号与当前任务账号不一致") ErrRemoteRevokeUnsupported = errors.New("当前版本不支持在 CLI 中安全撤销远程 Access Key") + ErrLoginWaitTimeout = errors.New("等待网页授权超时,尚未确认登录结果,请重新登录") + ErrAuthorizationExpired = errors.New("网页授权会话已过期,请重新登录") + ErrAuthorizationDenied = errors.New("用户已拒绝本次网页授权") + ErrAuthorizationPolicy = errors.New("当前账号暂不能使用 CLI 授权") + ErrSessionUnsupported = errors.New("服务端尚未支持跨设备授权;同机登录可显式使用 login --legacy-loopback") ) // Credential is the dedicated, device-scoped Access Key managed by this CLI. @@ -53,7 +59,10 @@ type LoginOptions struct { OpenURL func(string) error Progress io.Writer Timeout time.Duration - // ForceRefresh asks the browser page to rotate this device's rejected AK. + // LegacyLoopback explicitly selects the same-device compatibility flow. + // Network failures never silently downgrade cross-device authorization. + LegacyLoopback bool + // ForceRefresh asks the authorization service to rotate this device's rejected AK. // The CLI never calls QueryAk, DeleteAk, or GenerateAk itself. ForceRefresh bool // ExpectedCredentialScope binds reauthentication to the UID and device that From 7f786f2d6e110c1beee57456911828eeb0a714f7 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Sun, 20 Sep 2026 17:32:30 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=E8=A6=86=E7=9B=96=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E9=87=8D=E8=AF=95=E4=BF=9D=E5=AD=98=E4=B8=8E=E6=97=A7?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- README.md | 2 +- cmd/auth/auth_test.go | 14 + internal/auth/auth_test.go | 13 +- internal/auth/session_test.go | 424 +++++++++++++++++++++++++ skills/xyq-nest-skill/commands/auth.md | 5 +- 5 files changed, 453 insertions(+), 5 deletions(-) create mode 100644 internal/auth/session_test.go diff --git a/README.md b/README.md index 40385c7..74aba72 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,6 @@ pippit-tool-cli query-result \ ## 鉴权 -原生 CLI 命令通过 `pippit-tool-cli login` 打开小云雀网页授权,并把本机设备专属凭证保存到系统安全凭证库;Access Key 不会显示在终端。可用 `pippit-tool-cli status` 查看状态、`pippit-tool-cli logout` 清除本机登录。 +原生 CLI 命令通过 `pippit-tool-cli login` 显示小云雀网页授权链接与设备码。浏览器可以在另一台设备上打开链接,登录个人空间并核对设备码后确认,原终端会自动继续并把本机设备专属凭证保存到系统安全凭证库;Access Key 不会显示在终端。可用 `pippit-tool-cli status` 查看状态、`pippit-tool-cli logout` 清除本机登录。需要兼容旧服务且浏览器与 CLI 在同一台机器时,可显式使用 `login --legacy-loopback`。 CI 或 Agent 可继续显式设置 `XYQ_ACCESS_KEY`,它会覆盖本机网页登录凭证;配置错误时不会静默回退到个人登录。会话提交和查询共享上述凭据。 diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index 32f23f6..54db38b 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -57,6 +57,20 @@ func TestLoginCommandCanRequestSafeCredentialRotation(t *testing.T) { } } +func TestLoginCommandRequiresExplicitLegacyLoopback(t *testing.T) { + manager := &fakeAuthManager{credential: &internal_auth.Credential{ + UID: "123", CredentialScope: "account-device-scope", ExpiredAt: time.Now().Add(time.Hour).Unix(), + }} + command := NewLoginCommand(io.Discard, io.Discard, &common.Runner{Config: &config.Config{}, Auth: manager}) + command.SetArgs([]string{"--legacy-loopback"}) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + if len(manager.loginOptions) != 1 || !manager.loginOptions[0].LegacyLoopback { + t.Fatal("explicit legacy login option was lost") + } +} + func (manager *fakeAuthManager) Status(context.Context) (*internal_auth.Status, error) { return manager.status, nil } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index e2f7cf8..fedd19d 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -196,7 +196,8 @@ func TestManagerLoginStoresPageIssuedCredentialWithoutServerExchange(t *testing. var loginURLAtOpen string var loginURLShownBeforeOpen bool credential, err := manager.Login(context.Background(), LoginOptions{ - Progress: &progress, + LegacyLoopback: true, + Progress: &progress, OpenURL: func(rawURL string) error { loginURLAtOpen = rawURL loginURLShownBeforeOpen = strings.Contains(progress.String(), rawURL) @@ -257,7 +258,8 @@ func TestManagerWaitsForCallbackWhenBrowserOpenFails(t *testing.T) { ) var progress bytes.Buffer credential, err := manager.Login(context.Background(), LoginOptions{ - Progress: &progress, + LegacyLoopback: true, + Progress: &progress, OpenURL: func(rawURL string) error { loginURL, parseErr := url.Parse(rawURL) if parseErr != nil { @@ -304,6 +306,7 @@ func TestManagerReauthenticationPinsAccountAndRequiresRotation(t *testing.T) { store := &memoryCredentialStore{credential: cloneCredential(old)} manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow })) _, err := manager.Login(context.Background(), LoginOptions{ + LegacyLoopback: true, ForceRefresh: true, ExpectedCredentialScope: old.CredentialScope, OpenURL: callbackOpener(t, fixedNow.Add(time.Hour), "account-b", "new-id", "new-ak", true), @@ -320,6 +323,7 @@ func TestManagerReauthenticationPinsAccountAndRequiresRotation(t *testing.T) { store := &memoryCredentialStore{credential: cloneCredential(old)} manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow })) _, err := manager.Login(context.Background(), LoginOptions{ + LegacyLoopback: true, ForceRefresh: true, ExpectedCredentialScope: old.CredentialScope, OpenURL: callbackOpener(t, fixedNow.Add(time.Hour), old.UID, old.TokenID, old.AccessKey, true), @@ -336,6 +340,7 @@ func TestManagerReauthenticationPinsAccountAndRequiresRotation(t *testing.T) { store := &memoryCredentialStore{credential: cloneCredential(old)} manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow })) got, err := manager.Login(context.Background(), LoginOptions{ + LegacyLoopback: true, ForceRefresh: true, ExpectedCredentialScope: old.CredentialScope, OpenURL: callbackOpener(t, fixedNow.Add(time.Hour), old.UID, "rotated-id", "rotated-ak", true), @@ -423,7 +428,8 @@ func TestManagerRejectsExpiredPageCredential(t *testing.T) { store := &memoryCredentialStore{} manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow })) _, err := manager.Login(context.Background(), LoginOptions{ - OpenURL: callbackOpener(t, fixedNow.Add(-time.Hour), "user", "id", "ak", false), + LegacyLoopback: true, + OpenURL: callbackOpener(t, fixedNow.Add(-time.Hour), "user", "id", "ak", false), }) if !errors.Is(err, ErrCredentialExpired) { t.Fatalf("expired callback error = %v", err) @@ -442,6 +448,7 @@ func TestManagerDerivesExpectedAccountFromDurableScope(t *testing.T) { manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow })) expectedScope := credentialScope("durable-user", identity.DeviceID) _, err := manager.Login(context.Background(), LoginOptions{ + LegacyLoopback: true, ForceRefresh: true, ExpectedCredentialScope: expectedScope, OpenURL: func(rawURL string) error { diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go new file mode 100644 index 0000000..128d3fa --- /dev/null +++ b/internal/auth/session_test.go @@ -0,0 +1,424 @@ +package auth + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + "time" + + "github.com/Pippit-dev/pippit-cli/internal/config" +) + +var sessionTestNow = time.Unix(1_800_000_000, 0) + +func testSessionResponse() sessionResponse { + id := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)) + return sessionResponse{ + Session: authSession{ID: id, UserCode: "ABCD-2345", Status: "pending", ExpiresAt: sessionTestNow.Add(15 * time.Minute).Unix(), Interval: 5}, + ClaimSecret: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32)), + VerificationURI: authorizePagePath + "?session_id=" + id, + } +} + +func testAuthorizedResponse(created sessionResponse) sessionResponse { + created.ClaimSecret, created.VerificationURI = "", "" + created.Session.Status = "authorized" + created.Session.ClaimExpiresAt = sessionTestNow.Add(5 * time.Minute).Unix() + created.Credential = &sessionCredential{AccessKey: "secret-session-ak", TokenID: "new-token", UID: "123", ExpiredAt: sessionTestNow.Add(time.Hour).Unix()} + return created +} + +func writeSessionResponse(t *testing.T, writer http.ResponseWriter, response sessionResponse) { + t.Helper() + writer.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(writer).Encode(map[string]any{"ret": "0", "data": response}); err != nil { + t.Error(err) + } +} + +func sessionManagerForTest(t *testing.T, handler http.HandlerFunc) (*Manager, *memoryCredentialStore, *[]time.Duration) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + store := &memoryCredentialStore{} + cfg := config.Load() + cfg.AccessKey = "" + manager := NewManager(cfg, WithCredentialStore(store)) + manager.authBaseURL, _ = url.Parse(server.URL) + manager.authClient = server.Client() + now := sessionTestNow + manager.now = func() time.Time { return now } + delays := []time.Duration{} + manager.wait = func(ctx context.Context, delay time.Duration) error { + if err := ctx.Err(); err != nil { + return err + } + delays = append(delays, delay) + now = now.Add(delay) + return nil + } + manager.jitter = func() time.Duration { return 0 } + return manager, store, &delays +} + +func TestSessionLoginHeadlessSavesBeforeACKAndHidesSecrets(t *testing.T) { + created := testSessionResponse() + var store *memoryCredentialStore + var createRequest createSessionRequest + var opened bool + var acked bool + manager, memoryStore, _ := sessionManagerForTest(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.RawQuery != "" { + t.Error("unexpected request transport") + } + switch r.URL.Path { + case sessionAPIPath + "create": + if r.Header.Get("X-Cli-Session-Secret") != "" { + t.Error("creation included claim secret") + } + if err := json.NewDecoder(r.Body).Decode(&createRequest); err != nil { + t.Error(err) + } + writeSessionResponse(t, w, created) + case sessionAPIPath + "poll": + if !opened || r.Header.Get("X-Cli-Session-Secret") != created.ClaimSecret { + t.Error("unbound poll") + } + writeSessionResponse(t, w, testAuthorizedResponse(created)) + case sessionAPIPath + "ack": + var request sessionRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + store.mu.Lock() + saved := cloneCredential(store.credential) + store.mu.Unlock() + if saved == nil || saved.AccessKey != "secret-session-ak" || request.Result != "saved" || + request.TokenID != saved.TokenID || request.SessionID != created.Session.ID || r.Header.Get("X-Cli-Session-Secret") != created.ClaimSecret { + t.Error("ACK preceded save or has wrong binding") + } + acked = true + response := testAuthorizedResponse(created) + response.Session.Status, response.Credential = "completed", nil + writeSessionResponse(t, w, response) + default: + t.Error("unexpected endpoint") + w.WriteHeader(http.StatusNotFound) + } + }) + store = memoryStore + var progress bytes.Buffer + credential, err := manager.Login(context.Background(), LoginOptions{Progress: &progress, OpenURL: func(value string) error { + if !strings.Contains(progress.String(), value) || !strings.Contains(progress.String(), created.Session.UserCode) { + t.Error("URL and device code must be shown before browser launch") + } + opened = true + return errors.New("headless") + }}) + if err != nil || credential == nil || !acked { + t.Fatalf("login error=%v, ack=%v", err, acked) + } + if createRequest.DeviceID != credential.DeviceID || createRequest.Force || createRequest.ExpectedAccount != "" { + t.Error("unexpected create binding") + } + if strings.Contains(progress.String(), created.ClaimSecret) || strings.Contains(progress.String(), credential.AccessKey) { + t.Error("progress leaked credential") + } + resolved, err := manager.ResolveAccessKey(context.Background()) + if err != nil || resolved != credential.AccessKey { + t.Fatalf("saved credential is unusable: %v", err) + } +} + +func TestVerificationURLRejectsUntrustedOrSecretBearingLinks(t *testing.T) { + base, _ := url.Parse(config.DefaultBaseURL) + created := testSessionResponse() + for _, raw := range []string{ + "https://evil.example" + created.VerificationURI, + "//evil.example" + created.VerificationURI, + "http://xyq.jianying.com" + created.VerificationURI, + "https://user@xyq.jianying.com" + created.VerificationURI, + created.VerificationURI + "#fragment", + created.VerificationURI + "&claim_secret=secret", + created.VerificationURI + "&session_id=duplicate", + created.VerificationURI + "%zz", + "/wrong?session_id=" + created.Session.ID, + } { + if _, err := verificationURL(base, raw, created.Session.ID); err == nil { + t.Errorf("accepted unsafe verification URL: %s", raw) + } + } + for _, raw := range []string{created.VerificationURI, config.DefaultBaseURL + created.VerificationURI} { + if _, err := verificationURL(base, raw, created.Session.ID); err != nil { + t.Errorf("rejected trusted URL: %v", err) + } + } +} + +func TestSessionRedirectNeverForwardsClaimSecret(t *testing.T) { + forwarded := false + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { forwarded = true })) + defer target.Close() + manager, _, _ := sessionManagerForTest(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", target.URL) + w.WriteHeader(http.StatusTemporaryRedirect) + }) + _, err := manager.callSession(context.Background(), "poll", "never-forward-me", sessionRequest{SessionID: testSessionResponse().Session.ID}) + if err == nil || forwarded { + t.Fatalf("redirect was followed: forwarded=%v error=%v", forwarded, err) + } +} + +type failingSaveStore struct { + *memoryCredentialStore + failures int +} + +func (s *failingSaveStore) Save(ctx context.Context, credential *Credential) error { + if credential.AccessKey != "" && s.failures > 0 { + s.failures-- + return ErrSecureStore + } + return s.memoryCredentialStore.Save(ctx, credential) +} + +func TestSessionSaveRetryAndLostACKKeepSuccessfulLogin(t *testing.T) { + created := testSessionResponse() + var results []string + manager, store, _ := sessionManagerForTest(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case sessionAPIPath + "create": + writeSessionResponse(t, w, created) + case sessionAPIPath + "poll": + writeSessionResponse(t, w, testAuthorizedResponse(created)) + case sessionAPIPath + "ack": + var request sessionRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + results = append(results, request.Result) + if request.Result == "saved" { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + writeSessionResponse(t, w, created) + } + }) + manager.store = &failingSaveStore{memoryCredentialStore: store, failures: 1} + credential, err := manager.Login(context.Background(), LoginOptions{OpenURL: func(string) error { return nil }}) + if err != nil || credential == nil { + t.Fatalf("lost ACK undid successful save: %v", err) + } + if !reflect.DeepEqual(results, []string{"save_failed", "saved", "saved"}) { + t.Fatalf("ACK outcomes=%v", results) + } + if got, err := manager.ResolveAccessKey(context.Background()); err != nil || got != credential.AccessKey { + t.Fatalf("saved credential lost: %v", err) + } +} + +func TestSessionPollingBackoffAndServerExpiryClassification(t *testing.T) { + created := testSessionResponse() + polls := 0 + manager, _, delays := sessionManagerForTest(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == sessionAPIPath+"create" { + writeSessionResponse(t, w, created) + return + } + polls++ + switch polls { + case 1: + _, _ = io.WriteString(w, `{"ret":"6"}`) + case 2: + w.WriteHeader(http.StatusTooManyRequests) + case 3: + _, _ = io.WriteString(w, `{"ret":"4"}`) + case 4: + writeSessionResponse(t, w, created) + default: + created.Session.Status, created.Session.Reason = "expired", "awaiting_decision" + writeSessionResponse(t, w, created) + } + }) + _, err := manager.Login(context.Background(), LoginOptions{OpenURL: func(string) error { return nil }}) + if !errors.Is(err, ErrAuthorizationExpired) || !strings.Contains(err.Error(), "未在有效期内确认") { + t.Fatalf("wrong expiry classification: %v", err) + } + if want := []time.Duration{5 * time.Second, 10 * time.Second, 10 * time.Second, 20 * time.Second, 10 * time.Second}; !reflect.DeepEqual(*delays, want) { + t.Fatalf("poll delays=%v, want=%v", *delays, want) + } +} + +func TestSessionDoesNotDowngradeUnsupportedServer(t *testing.T) { + opened := false + manager, _, _ := sessionManagerForTest(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) }) + _, err := manager.Login(context.Background(), LoginOptions{OpenURL: func(string) error { opened = true; return nil }}) + if !errors.Is(err, ErrSessionUnsupported) || opened { + t.Fatalf("unexpected fallback: opened=%v err=%v", opened, err) + } +} + +func TestSessionPreservesBindingAndRotationValidation(t *testing.T) { + deviceID := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32)) + identity := &Credential{Version: credentialVersion, DeviceID: deviceID, AccessKey: "old-key", TokenID: "old-token", UID: "123"} + request := createSessionRequest{DeviceID: deviceID, TokenID: "old-token", ExpectedAccount: accountBinding("123"), Force: true} + manager := NewManager(config.Load(), withClockForTest(func() time.Time { return sessionTestNow })) + for _, test := range []struct { + name string + modify func(*sessionCredential) + }{ + {"account", func(c *sessionCredential) { c.UID = "456" }}, + {"team", func(c *sessionCredential) { c.TeamID = "999" }}, + {"old token", func(c *sessionCredential) { c.TokenID = "old-token" }}, + {"old key", func(c *sessionCredential) { c.AccessKey = "old-key" }}, + {"expired", func(c *sessionCredential) { c.ExpiredAt = sessionTestNow.Unix() }}, + {"missing key", func(c *sessionCredential) { c.AccessKey = "" }}, + } { + t.Run(test.name, func(t *testing.T) { + payload := testAuthorizedResponse(testSessionResponse()).Credential + test.modify(payload) + if _, err := manager.validateSessionCredential(identity, request, LoginOptions{}, payload); err == nil { + t.Error("accepted invalid credential") + } + }) + } + if _, err := manager.validateSessionCredential(identity, request, LoginOptions{ExpectedCredentialScope: credentialScope("123", deviceID)}, testAuthorizedResponse(testSessionResponse()).Credential); err != nil { + t.Fatalf("valid rotation rejected: %v", err) + } +} + +func TestSessionTotalWaitTimeoutIsDistinctFromRequestTimeout(t *testing.T) { + manager, _, _ := sessionManagerForTest(t, func(w http.ResponseWriter, r *http.Request) { writeSessionResponse(t, w, testSessionResponse()) }) + manager.wait = func(context.Context, time.Duration) error { return context.DeadlineExceeded } + _, err := manager.Login(context.Background(), LoginOptions{Timeout: time.Second, OpenURL: func(string) error { return nil }}) + if !errors.Is(err, ErrLoginWaitTimeout) || errors.Is(err, ErrAuthorizationExpired) { + t.Fatalf("wrong timeout: %v", err) + } +} + +func TestSessionValidationRejectsBindingChanges(t *testing.T) { + created := testSessionResponse() + for _, mutate := range []func(*authSession){ + func(s *authSession) { s.ID = "invalid" }, + func(s *authSession) { s.ExpectedAccount = accountBinding("other") }, + func(s *authSession) { s.Force = true }, + func(s *authSession) { s.TeamID = "888" }, + func(s *authSession) { s.Status = "unknown" }, + func(s *authSession) { s.UserCode = "bad\ncode" }, + func(s *authSession) { s.Interval = 1 << 62 }, + } { + session := created.Session + mutate(&session) + if validateSession(session, createSessionRequest{}, created.Session.ID) == nil { + t.Error("accepted invalid session") + } + } +} + +func TestSessionCanClaimApprovalAtAuthorizationDeadline(t *testing.T) { + created := testSessionResponse() + created.Session.ExpiresAt = sessionTestNow.Add(6 * time.Second).Unix() + polls := 0 + manager, _, _ := sessionManagerForTest(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == sessionAPIPath+"poll" { + polls++ + if polls > 1 { + response := testAuthorizedResponse(created) + response.Session.ClaimExpiresAt = created.Session.ExpiresAt + int64(claimWindow/time.Second) + writeSessionResponse(t, w, response) + return + } + } + writeSessionResponse(t, w, created) + }) + credential, err := manager.Login(context.Background(), LoginOptions{OpenURL: func(string) error { return nil }}) + if err != nil || credential == nil || polls != 2 { + t.Fatalf("deadline approval lost: polls=%d err=%v", polls, err) + } +} + +func TestSessionAccountMismatchDoesNotOverwriteOrACK(t *testing.T) { + created := testSessionResponse() + created.Session.ExpectedAccount = accountBinding("original-user") + acked := false + manager, store, _ := sessionManagerForTest(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case sessionAPIPath + "create": + writeSessionResponse(t, w, created) + case sessionAPIPath + "poll": + writeSessionResponse(t, w, testAuthorizedResponse(created)) + case sessionAPIPath + "ack": + acked = true + writeSessionResponse(t, w, created) + } + }) + deviceID := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32)) + store.credential = &Credential{Version: credentialVersion, DeviceID: deviceID, UID: "original-user", AccessKey: "original-ak", TokenID: "original-token", ExpiredAt: sessionTestNow.Add(time.Hour).Unix()} + _, err := manager.Login(context.Background(), LoginOptions{OpenURL: func(string) error { return nil }}) + if !errors.Is(err, ErrCredentialAccountMismatch) || acked { + t.Fatalf("invalid account accepted/ACKed: %v", err) + } + if store.credential.AccessKey != "original-ak" || store.saves != 0 { + t.Fatal("existing credential was overwritten") + } +} + +func TestSessionTransportTimeoutDoesNotExposeUnderlyingError(t *testing.T) { + err := sessionTransportError(context.Background(), &url.Error{Op: "Post", URL: "https://example.invalid/secret", Err: context.DeadlineExceeded}) + var requestErr *sessionRequestError + if !errors.As(err, &requestErr) || !requestErr.timeout || !requestErr.retryable || strings.Contains(err.Error(), "secret") { + t.Fatalf("incorrect timeout classification: %v", err) + } + if errors.Is(err, ErrLoginWaitTimeout) { + t.Fatal("individual request timeout became total login timeout") + } + if got := retryAfterDelay("120", sessionTestNow); got != 2*time.Minute { + t.Fatalf("Retry-After=%v", got) + } +} + +func TestSessionLoginAfterLogoutUsesDeviceWithoutStaleAccountSelector(t *testing.T) { + created := testSessionResponse() + var request createSessionRequest + manager, store, _ := sessionManagerForTest(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == sessionAPIPath+"create" { + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + } + if r.URL.Path == sessionAPIPath+"poll" { + writeSessionResponse(t, w, testAuthorizedResponse(created)) + return + } + writeSessionResponse(t, w, created) + }) + deviceID := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32)) + store.credential = &Credential{Version: credentialVersion, DeviceID: deviceID, TokenID: "old-account-token"} + credential, err := manager.Login(context.Background(), LoginOptions{OpenURL: func(string) error { return nil }}) + if err != nil || credential == nil { + t.Fatalf("login after logout failed: %v", err) + } + if request.DeviceID != deviceID || request.TokenID != "" || request.ExpectedAccount != "" || request.Force { + t.Error("stale account selector sent after logout") + } +} + +func TestSessionHTTPTimeoutRemainsRequestTimeout(t *testing.T) { + for _, status := range []int{http.StatusGatewayTimeout, http.StatusRequestTimeout} { + manager, _, _ := sessionManagerForTest(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status) }) + _, err := manager.callSession(context.Background(), "create", "", createSessionRequest{}) + var requestErr *sessionRequestError + if !errors.As(err, &requestErr) || !requestErr.timeout || !requestErr.retryable || errors.Is(err, ErrLoginWaitTimeout) { + t.Fatalf("HTTP %d incorrectly classified: %v", status, err) + } + } +} diff --git a/skills/xyq-nest-skill/commands/auth.md b/skills/xyq-nest-skill/commands/auth.md index 064f93d..ceaf007 100644 --- a/skills/xyq-nest-skill/commands/auth.md +++ b/skills/xyq-nest-skill/commands/auth.md @@ -16,7 +16,9 @@ pippit-tool-cli status pippit-tool-cli login ``` -引导用户在 CLI 提供的浏览器页面完成授权,等待命令成功返回 `logged_in=true`。CLI 自动申请或复用本机凭据并保存到系统安全凭证库。无浏览器交互能力且没有可用凭据时,报告授权阻塞。 +引导用户打开 CLI 提供的授权地址,可以使用另一台设备的浏览器。用户登录个人空间、核对终端显示的设备码并明确确认后,原终端会自动继续。等待命令成功返回 `logged_in=true`;CLI 申请或复用本机凭据并保存到系统安全凭证库。远程终端没有浏览器时仍可显示链接并等待,不应直接报告授权阻塞。用户无法在任何浏览器确认且没有可用凭据时,再报告授权阻塞。 + +授权链接 15 分钟内有效,确认后另有 5 分钟领取窗口。CLI 默认最多等待 20 分钟;取消后需要重新发起登录。网页确认成功并不代表本机保存成功,应以 CLI 的结果为准。 用户要求退出时执行: @@ -31,5 +33,6 @@ pippit-tool-cli logout - `XYQ_ACCESS_KEY` 是 CI、Agent 等环境的显式覆盖,优先于网页登录凭据。已设置但无效时不会自动回退;应修正该环境的配置或取消覆盖,再重试原操作。 - `logout` 不清除环境变量;返回 `environment_still_active=true` 时,显式密钥仍生效。不要把本机退出解释成所有凭据均已失效。 - 浏览器登录凭据被服务端拒绝时可使用 `pippit-tool-cli login --force` 轮换本机密钥;不要作为每次调用的例行步骤。 +- 仅当浏览器与 CLI 在同一台机器、且需要兼容旧授权服务时,显式使用 `pippit-tool-cli login --legacy-loopback`。新流程不会因网络错误自动切换为本机回调;旧服务尚未部署新接口时会明确提示。 - 不展示、回显或把密钥写入文档和命令参数。错误信息中出现凭据时,向用户展示前须隐藏凭据。 - 带凭据的 API 请求固定发往 `https://xyq.jianying.com`,不通过修改 API 域名修复鉴权问题。 From eaee6dd5fc87db58045597ec06677c1919454e89 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Mon, 21 Sep 2026 16:45:13 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(auth):=20=E5=8C=BA=E5=88=86=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E7=BB=93=E6=9E=9C=E4=B8=8D=E7=A1=AE=E5=AE=9A=E5=B9=B6?= =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=9F=A5=E8=AF=A2=E5=BD=93=E5=89=8D=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- internal/auth/session_login.go | 28 ++++++++ internal/auth/session_test.go | 124 +++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/internal/auth/session_login.go b/internal/auth/session_login.go index f5dc2b9..ddf3ef2 100644 --- a/internal/auth/session_login.go +++ b/internal/auth/session_login.go @@ -10,6 +10,18 @@ import ( "time" ) +// ErrAuthorizationUncertain means a key mutation was attempted but its result +// has not been confirmed. Starting another login may repeat that mutation. +var ErrAuthorizationUncertain = errors.New("本次授权结果尚未确认,密钥可能已创建或更换;请先核对终端和账号中的密钥状态,不要直接重复授权或换钥") + +type authorizationUncertainError struct{ cause error } + +func (e *authorizationUncertainError) Error() string { return ErrAuthorizationUncertain.Error() } +func (e *authorizationUncertainError) Unwrap() error { return e.cause } +func (e *authorizationUncertainError) Is(target error) bool { + return target == ErrAuthorizationUncertain +} + func (m *Manager) loginSession(ctx context.Context, options LoginOptions) (*Credential, error) { if err := m.validate(); err != nil { return nil, err @@ -24,6 +36,9 @@ func (m *Manager) loginSession(ctx context.Context, options LoginOptions) (*Cred waitCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() credential, err := m.runSessionLogin(waitCtx, options) + if errors.Is(err, ErrAuthorizationUncertain) { + return credential, err + } if errors.Is(err, context.DeadlineExceeded) { return nil, ErrLoginWaitTimeout } @@ -93,14 +108,21 @@ func (m *Manager) runSessionLogin(ctx context.Context, options LoginOptions) (*C serverDeadline := time.Unix(created.Session.ExpiresAt, 0).Add(claimWindow) interval := max(minimumPollInterval, time.Duration(created.Session.Interval)*time.Second) delay := interval + authorizationUncertain := false for { if err := m.waitSession(ctx, delay, serverDeadline); err != nil { + if authorizationUncertain { + return nil, &authorizationUncertainError{cause: err} + } return nil, err } result, pollErr := m.callSession(ctx, "poll", created.ClaimSecret, sessionRequest{SessionID: created.Session.ID}) if pollErr != nil { var requestErr *sessionRequestError if !errors.As(pollErr, &requestErr) || !requestErr.retryable { + if authorizationUncertain { + return nil, &authorizationUncertainError{cause: pollErr} + } return nil, pollErr } delay, interval = nextPollDelay(delay, interval, requestErr) @@ -119,6 +141,10 @@ func (m *Manager) runSessionLogin(ctx context.Context, options LoginOptions) (*C if result.Credential != nil { return nil, errors.New("未确认授权返回了凭据") } + if result.Session.Reason == "authorization_uncertain" && !authorizationUncertain { + authorizationUncertain = true + writeProgress(options.Progress, "授权结果尚未确认,CLI 正在继续查询当前会话;请勿重复发起授权或更换密钥。") + } continue case "denied": if result.Session.Reason == "policy_denied" { @@ -250,6 +276,8 @@ func nextPollDelay(delay, interval time.Duration, requestErr *sessionRequestErro func sessionExpiredError(reason string) error { switch reason { + case "authorization_uncertain": + return ErrAuthorizationUncertain case "no_observed_action": return fmt.Errorf("%w(服务端未观察到打开授权页面)", ErrAuthorizationExpired) case "awaiting_decision": diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go index 128d3fa..a1ea7d5 100644 --- a/internal/auth/session_test.go +++ b/internal/auth/session_test.go @@ -422,3 +422,127 @@ func TestSessionHTTPTimeoutRemainsRequestTimeout(t *testing.T) { } } } + +func TestSessionUncertainAuthorizationReconcilesWithoutRestartOrRotation(t *testing.T) { + created := testSessionResponse() + creates, polls, acks := 0, 0, 0 + manager, _, delays := sessionManagerForTest(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case sessionAPIPath + "create": + creates++ + var request createSessionRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + if request.Force { + t.Error("unexpected automatic key rotation") + } + writeSessionResponse(t, w, created) + case sessionAPIPath + "poll": + polls++ + var request sessionRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + if request.SessionID != created.Session.ID || r.Header.Get("X-Cli-Session-Secret") != created.ClaimSecret { + t.Error("poll switched the authorization session") + } + switch polls { + case 1, 4: + response := created + response.Session.Reason = "authorization_uncertain" + writeSessionResponse(t, w, response) + case 2: + _, _ = io.WriteString(w, `{"ret":"6"}`) + case 3: + _, _ = io.WriteString(w, `{"ret":"20002"}`) + default: + writeSessionResponse(t, w, testAuthorizedResponse(created)) + } + case sessionAPIPath + "ack": + acks++ + writeSessionResponse(t, w, created) + default: + t.Error("unexpected endpoint") + w.WriteHeader(http.StatusNotFound) + } + }) + var progress bytes.Buffer + credential, err := manager.Login(context.Background(), LoginOptions{Progress: &progress, OpenURL: func(string) error { return nil }}) + if err != nil || credential == nil || creates != 1 || polls != 5 || acks != 1 { + t.Fatalf("uncertain authorization was not reconciled: creates=%d polls=%d acks=%d err=%v", creates, polls, acks, err) + } + if want := []time.Duration{5 * time.Second, 5 * time.Second, 10 * time.Second, 20 * time.Second, 5 * time.Second}; !reflect.DeepEqual(*delays, want) { + t.Fatalf("poll delays=%v, want=%v", *delays, want) + } + if strings.Count(progress.String(), "授权结果尚未确认") != 1 || !strings.Contains(progress.String(), "请勿重复发起授权或更换密钥") { + t.Error("uncertainty must be explained once while continuing the same session") + } + if strings.Contains(progress.String(), created.ClaimSecret) || strings.Contains(progress.String(), credential.AccessKey) { + t.Error("progress leaked credential") + } +} + +func TestSessionUncertainAuthorizationExpiryPreservesCredentialsAndClassification(t *testing.T) { + for _, test := range []struct { + name string + waitErr error + }{ + {name: "server expiry"}, + {name: "local deadline", waitErr: context.DeadlineExceeded}, + {name: "server wait deadline", waitErr: ErrLoginWaitTimeout}, + {name: "cancelled wait", waitErr: context.Canceled}, + } { + t.Run(test.name, func(t *testing.T) { + created := testSessionResponse() + created.Session.ExpectedAccount = accountBinding("123") + creates, polls, acks := 0, 0, 0 + manager, store, _ := sessionManagerForTest(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case sessionAPIPath + "create": + creates++ + writeSessionResponse(t, w, created) + case sessionAPIPath + "poll": + polls++ + response := created + response.Session.Reason = "authorization_uncertain" + if polls > 1 { + response.Session.Status = "expired" + } + writeSessionResponse(t, w, response) + case sessionAPIPath + "ack": + acks++ + writeSessionResponse(t, w, created) + default: + t.Error("unexpected endpoint") + w.WriteHeader(http.StatusNotFound) + } + }) + deviceID := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32)) + original := &Credential{Version: credentialVersion, DeviceID: deviceID, UID: "123", AccessKey: "original-ak", TokenID: "original-token", ExpiredAt: sessionTestNow.Add(time.Hour).Unix()} + store.credential = cloneCredential(original) + if test.waitErr != nil { + wait := manager.wait + manager.wait = func(ctx context.Context, delay time.Duration) error { + if polls > 0 { + return test.waitErr + } + return wait(ctx, delay) + } + } + _, err := manager.Login(context.Background(), LoginOptions{OpenURL: func(string) error { return nil }}) + if !errors.Is(err, ErrAuthorizationUncertain) || errors.Is(err, ErrAuthorizationExpired) { + t.Fatalf("uncertainty lost at expiry: %v", err) + } + if test.waitErr != nil && !errors.Is(err, test.waitErr) { + t.Fatalf("underlying stop reason lost: %v", err) + } + if strings.Contains(err.Error(), "请重新登录") || !strings.Contains(err.Error(), "不要直接重复授权或换钥") { + t.Fatalf("unsafe retry advice: %v", err) + } + if creates != 1 || acks != 0 || store.saves != 0 || !reflect.DeepEqual(store.credential, original) { + t.Fatalf("uncertain result restarted, ACKed or replaced credentials: creates=%d acks=%d saves=%d", creates, acks, store.saves) + } + }) + } +}