oauth_refresh_api.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "log/slog"
  7. "strconv"
  8. "strings"
  9. "sync"
  10. "time"
  11. )
  12. // OAuthRefreshExecutor 各平台实现的 OAuth 刷新执行器
  13. // TokenRefresher 接口的超集:增加了 CacheKey 方法用于分布式锁
  14. type OAuthRefreshExecutor interface {
  15. TokenRefresher
  16. // CacheKey 返回用于分布式锁的缓存键(与 TokenProvider 使用的一致)
  17. CacheKey(account *Account) string
  18. }
  19. // GrokOAuthRefreshSuccessRepository is the persistence boundary for a
  20. // provider-issued Grok credential rotation. Implementations must compare the
  21. // complete credential document and proxy used by the upstream attempt, and
  22. // atomically publish scheduler invalidation with a successful update.
  23. type GrokOAuthRefreshSuccessRepository interface {
  24. UpdateGrokOAuthCredentialsIfUnchanged(
  25. ctx context.Context,
  26. id int64,
  27. expectedCredentials map[string]any,
  28. expectedProxyID *int64,
  29. credentials map[string]any,
  30. ) (bool, error)
  31. }
  32. const (
  33. defaultRefreshLockTTL = 60 * time.Second
  34. defaultRefreshLockReleaseTimeout = 2 * time.Second
  35. defaultRefreshPostPersistCleanupTimeout = 2 * time.Second
  36. )
  37. var (
  38. errOAuthRefreshAccountRereadFailed = errors.New("oauth refresh account reread failed")
  39. errOAuthRefreshAccountStateChanged = errors.New("oauth refresh account state changed")
  40. errOAuthRefreshCredentialPersist = errors.New("oauth refresh credential persistence failed")
  41. )
  42. type oauthRefreshRequestPathKey struct{}
  43. func withOAuthRefreshRequestPath(ctx context.Context) context.Context {
  44. return context.WithValue(ctx, oauthRefreshRequestPathKey{}, true)
  45. }
  46. func isOAuthRefreshRequestPath(ctx context.Context) bool {
  47. requestPath, _ := ctx.Value(oauthRefreshRequestPathKey{}).(bool)
  48. return requestPath
  49. }
  50. type contextMutex struct {
  51. token chan struct{}
  52. }
  53. // Keep the request-path credential mutation lock API introduced by #4212
  54. // while sharing the context-aware mutex implementation used by pool refresh.
  55. type oauthRefreshLocalLock = contextMutex
  56. func newOAuthRefreshLocalLock() *oauthRefreshLocalLock {
  57. return newContextMutex()
  58. }
  59. type oauthRefreshStateUnavailableError struct {
  60. err error
  61. }
  62. func (e *oauthRefreshStateUnavailableError) Error() string {
  63. return "OAuth refresh account state is unavailable"
  64. }
  65. func (e *oauthRefreshStateUnavailableError) Unwrap() error {
  66. if e == nil {
  67. return nil
  68. }
  69. return e.err
  70. }
  71. func newContextMutex() *contextMutex {
  72. return &contextMutex{token: make(chan struct{}, 1)}
  73. }
  74. func (m *contextMutex) Lock(ctx context.Context) error {
  75. select {
  76. case m.token <- struct{}{}:
  77. return nil
  78. case <-ctx.Done():
  79. return ctx.Err()
  80. }
  81. }
  82. func (m *contextMutex) Unlock() {
  83. <-m.token
  84. }
  85. // OAuthRefreshResult 统一刷新结果
  86. type OAuthRefreshResult struct {
  87. Refreshed bool // 实际执行了刷新
  88. NewCredentials map[string]any // 刷新后的 credentials(nil 表示未刷新)
  89. Account *Account // 成功时为最新 account;刷新错误时为实际尝试的凭据快照
  90. LockHeld bool // 锁被其他 worker 持有(未执行刷新)
  91. }
  92. func snapshotOAuthRefreshAccount(account *Account) *Account {
  93. if account == nil {
  94. return nil
  95. }
  96. snapshot := *account
  97. snapshot.Credentials = shallowCopyMap(account.Credentials)
  98. if account.ProxyID != nil {
  99. proxyID := *account.ProxyID
  100. snapshot.ProxyID = &proxyID
  101. }
  102. return &snapshot
  103. }
  104. // OAuthRefreshAPI 统一的 OAuth Token 刷新入口
  105. // 封装分布式锁、进程内互斥锁、DB 重读、已刷新检查、竞争恢复等通用逻辑
  106. type OAuthRefreshAPI struct {
  107. accountRepo AccountRepository
  108. tokenCache GeminiTokenCache // 可选,nil = 无分布式锁
  109. lockTTL time.Duration
  110. localLocks sync.Map // key: cacheKey string -> value: *contextMutex
  111. }
  112. // NewOAuthRefreshAPI 创建统一刷新 API
  113. // 可选传入 lockTTL 覆盖默认的 60s 分布式锁 TTL
  114. func NewOAuthRefreshAPI(accountRepo AccountRepository, tokenCache GeminiTokenCache, lockTTL ...time.Duration) *OAuthRefreshAPI {
  115. ttl := defaultRefreshLockTTL
  116. if len(lockTTL) > 0 && lockTTL[0] > 0 {
  117. ttl = lockTTL[0]
  118. }
  119. return &OAuthRefreshAPI{
  120. accountRepo: accountRepo,
  121. tokenCache: tokenCache,
  122. lockTTL: ttl,
  123. }
  124. }
  125. // getLocalLock 返回指定 cacheKey 的进程内互斥锁
  126. func (api *OAuthRefreshAPI) getLocalLock(cacheKey string) *contextMutex {
  127. actual, _ := api.localLocks.LoadOrStore(cacheKey, newContextMutex())
  128. mu, ok := actual.(*contextMutex)
  129. if !ok {
  130. mu = newContextMutex()
  131. api.localLocks.Store(cacheKey, mu)
  132. }
  133. return mu
  134. }
  135. // RefreshIfNeeded 在分布式锁保护下按需刷新 OAuth token
  136. //
  137. // 流程:
  138. // 1. 获取分布式锁
  139. // 2. 从 DB 重读最新 account(防止使用过时的 refresh_token)
  140. // 3. 二次检查是否仍需刷新
  141. // 4. 调用 executor.Refresh() 执行平台特定刷新逻辑
  142. // 5. 设置 _token_version + 更新 DB
  143. // 6. 释放锁
  144. func (api *OAuthRefreshAPI) RefreshIfNeeded(
  145. ctx context.Context,
  146. account *Account,
  147. executor OAuthRefreshExecutor,
  148. refreshWindow time.Duration,
  149. ) (*OAuthRefreshResult, error) {
  150. if intelligentContext(ctx) != nil {
  151. return nil, errIntelligentCredentialRefreshRequired
  152. }
  153. if api == nil || api.accountRepo == nil {
  154. return nil, errors.New("oauth refresh account repository is not configured")
  155. }
  156. if account == nil {
  157. return nil, errors.New("oauth refresh account is nil")
  158. }
  159. if executor == nil {
  160. return nil, errors.New("oauth refresh executor is nil")
  161. }
  162. requestPath := isOAuthRefreshRequestPath(ctx)
  163. cacheKey := executor.CacheKey(account)
  164. // 0. 获取进程内互斥锁(防止同一进程内的并发刷新竞争)
  165. localMu := api.getLocalLock(cacheKey)
  166. if err := localMu.Lock(ctx); err != nil {
  167. return nil, fmt.Errorf("oauth refresh local lock: %w", err)
  168. }
  169. defer localMu.Unlock()
  170. // 1. 获取分布式锁
  171. if api.tokenCache != nil {
  172. acquired, lockErr := api.tokenCache.AcquireRefreshLock(ctx, cacheKey, api.lockTTL)
  173. if lockErr != nil {
  174. // Redis 错误,降级为无锁刷新(进程内互斥锁仍生效)
  175. slog.Warn("oauth_refresh_lock_failed_degraded",
  176. "account_id", account.ID,
  177. "cache_key", cacheKey,
  178. "error", lockErr,
  179. )
  180. } else if !acquired {
  181. // 锁被其他 worker 持有
  182. return &OAuthRefreshResult{LockHeld: true}, nil
  183. } else {
  184. defer api.releaseRefreshLock(ctx, cacheKey)
  185. }
  186. }
  187. // 2. 从 DB 重读最新 account(锁保护下,确保使用最新的 refresh_token)
  188. freshAccount, err := api.accountRepo.GetByID(ctx, account.ID)
  189. if err != nil {
  190. if requestPath {
  191. return nil, fmt.Errorf("%w: %v", errOAuthRefreshAccountRereadFailed, err)
  192. }
  193. return nil, &oauthRefreshStateUnavailableError{err: err}
  194. }
  195. if freshAccount == nil {
  196. if requestPath {
  197. return nil, fmt.Errorf("%w: account not found", errOAuthRefreshAccountStateChanged)
  198. }
  199. return nil, &oauthRefreshStateUnavailableError{err: fmt.Errorf("account not found")}
  200. }
  201. if freshAccount.ID != account.ID {
  202. return nil, fmt.Errorf("%w: account identity mismatch", errOAuthRefreshAccountRereadFailed)
  203. }
  204. if !freshAccount.IsActive() {
  205. if requestPath {
  206. return nil, fmt.Errorf("%w: account is not active", errOAuthRefreshAccountStateChanged)
  207. }
  208. return &OAuthRefreshResult{Account: freshAccount}, nil
  209. }
  210. if requestPath && freshAccount.Platform == PlatformGrok {
  211. if eligibilityErr := grokOAuthRequestAccountEligibilityError(freshAccount); eligibilityErr != nil {
  212. return nil, withGrokCredentialFailureSnapshot(eligibilityErr, freshAccount)
  213. }
  214. }
  215. if !executor.CanRefresh(freshAccount) {
  216. if requestPath && freshAccount.IsGrokOAuth() && strings.TrimSpace(freshAccount.GetGrokRefreshToken()) == "" {
  217. return nil, withGrokCredentialFailureSnapshot(errGrokOAuthRefreshTokenMissing, freshAccount)
  218. }
  219. if requestPath {
  220. return nil, fmt.Errorf("%w: account is no longer refreshable", errOAuthRefreshAccountStateChanged)
  221. }
  222. return &OAuthRefreshResult{Account: freshAccount}, nil
  223. }
  224. // 3. 二次检查是否仍需刷新(另一条路径可能已刷新)
  225. if !executor.NeedsRefresh(freshAccount, refreshWindow) {
  226. return &OAuthRefreshResult{
  227. Account: freshAccount,
  228. }, nil
  229. }
  230. // 4. 执行平台特定刷新逻辑
  231. attemptedAccount := snapshotOAuthRefreshAccount(freshAccount)
  232. newCredentials, refreshErr := executor.Refresh(ctx, freshAccount)
  233. if ctxErr := ctx.Err(); ctxErr != nil {
  234. // A provider implementation may ignore cancellation and return late
  235. // credentials. Never persist them after the attempt/cycle boundary.
  236. return nil, ctxErr
  237. }
  238. if refreshErr != nil {
  239. // 竞争恢复:invalid_grant 可能是另一个 worker 已消费了旧 refresh_token
  240. // 重新读取 DB,如果 refresh_token 已更新则说明是竞争,返回成功
  241. if isInvalidGrantError(refreshErr) {
  242. if recoveredAccount, recovered := api.tryRecoverFromRefreshRace(ctx, freshAccount); recovered {
  243. if requestPath && recoveredAccount.Platform == PlatformGrok {
  244. if eligibilityErr := grokOAuthRequestAccountEligibilityError(recoveredAccount); eligibilityErr != nil {
  245. return nil, withGrokCredentialFailureSnapshot(eligibilityErr, recoveredAccount)
  246. }
  247. }
  248. slog.Info("oauth_refresh_race_recovered",
  249. "account_id", freshAccount.ID,
  250. "platform", freshAccount.Platform,
  251. )
  252. return &OAuthRefreshResult{
  253. Account: recoveredAccount,
  254. }, nil
  255. }
  256. }
  257. // Preserve the exact account snapshot used by the failed upstream call.
  258. // Callers can then conditionally mutate only that credential version and
  259. // avoid quarantining a concurrently reauthorized account.
  260. result := &OAuthRefreshResult{Account: attemptedAccount}
  261. if requestPath && attemptedAccount.Platform == PlatformGrok {
  262. return result, withGrokCredentialFailureSnapshot(refreshErr, attemptedAccount)
  263. }
  264. return result, refreshErr
  265. }
  266. // 5. 设置版本号 + 更新 DB
  267. if newCredentials != nil {
  268. newCredentials["_token_version"] = time.Now().UnixMilli()
  269. if freshAccount.IsGrokOAuth() {
  270. conditionalRepo, ok := api.accountRepo.(GrokOAuthRefreshSuccessRepository)
  271. if !ok {
  272. return nil, &providerConfigurationRefreshError{
  273. err: fmt.Errorf("grok OAuth refresh success CAS repository is not configured"),
  274. }
  275. }
  276. applied, updateErr := conditionalRepo.UpdateGrokOAuthCredentialsIfUnchanged(
  277. ctx,
  278. freshAccount.ID,
  279. attemptedAccount.Credentials,
  280. attemptedAccount.ProxyID,
  281. newCredentials,
  282. )
  283. if updateErr != nil {
  284. slog.Error("oauth_refresh_update_failed",
  285. "account_id", freshAccount.ID,
  286. "platform", freshAccount.Platform,
  287. "error", updateErr,
  288. )
  289. // The provider may have rotated and consumed the refresh token.
  290. // Retrying after an ambiguous local persistence result can turn a
  291. // healthy account into invalid_grant, so contain this provider cycle.
  292. return nil, &providerCycleContainmentRefreshError{
  293. err: fmt.Errorf("OAuth refresh succeeded but credential persistence failed: %w", updateErr),
  294. }
  295. }
  296. if !applied {
  297. currentAccount, readErr := api.accountRepo.GetByID(ctx, freshAccount.ID)
  298. if readErr != nil || currentAccount == nil {
  299. if readErr == nil {
  300. readErr = fmt.Errorf("account not found after Grok OAuth success CAS miss")
  301. }
  302. return nil, &providerCycleContainmentRefreshError{
  303. err: fmt.Errorf("grok OAuth success CAS lost and current state is unavailable: %w", readErr),
  304. }
  305. }
  306. slog.Info("oauth_refresh_success_cas_skipped_stale_credentials",
  307. "account_id", freshAccount.ID,
  308. "platform", freshAccount.Platform,
  309. )
  310. return &OAuthRefreshResult{Account: currentAccount}, nil
  311. }
  312. durableAccount, readErr := api.loadGrokDurableAccountAfterPersist(ctx, cacheKey, freshAccount.ID)
  313. if readErr != nil || durableAccount == nil {
  314. if readErr == nil {
  315. readErr = fmt.Errorf("account not found after Grok OAuth success CAS")
  316. }
  317. return nil, &providerCycleContainmentRefreshError{
  318. err: fmt.Errorf("grok OAuth success persisted but durable account state is unavailable: %w", readErr),
  319. }
  320. }
  321. // The CAS changes credentials only. A concurrent admin or scheduler
  322. // mutation may have changed status, schedulability, or cooldown fields
  323. // while the provider call was in flight. Return the durable row so
  324. // post-refresh cache publication cannot restore that stale snapshot.
  325. freshAccount = durableAccount
  326. } else if updateErr := persistAccountCredentials(ctx, api.accountRepo, freshAccount, newCredentials); updateErr != nil {
  327. slog.Error("oauth_refresh_update_failed",
  328. "account_id", freshAccount.ID,
  329. "error", updateErr,
  330. )
  331. return nil, fmt.Errorf("%w: %v", errOAuthRefreshCredentialPersist, updateErr)
  332. }
  333. }
  334. if requestPath && freshAccount.Platform == PlatformGrok {
  335. if eligibilityErr := grokOAuthRequestAccountEligibilityError(freshAccount); eligibilityErr != nil {
  336. return nil, withGrokCredentialFailureSnapshot(eligibilityErr, freshAccount)
  337. }
  338. }
  339. return &OAuthRefreshResult{
  340. Refreshed: true,
  341. NewCredentials: newCredentials,
  342. Account: freshAccount,
  343. }, nil
  344. }
  345. func (api *OAuthRefreshAPI) releaseRefreshLock(parent context.Context, cacheKey string) {
  346. cleanupParent := context.Background()
  347. if parent != nil {
  348. cleanupParent = context.WithoutCancel(parent)
  349. }
  350. ctx, cancel := context.WithTimeout(cleanupParent, defaultRefreshLockReleaseTimeout)
  351. defer cancel()
  352. if err := api.tokenCache.ReleaseRefreshLock(ctx, cacheKey); err != nil {
  353. slog.Warn("oauth_refresh_lock_release_failed", "cache_key", cacheKey, "error", err)
  354. }
  355. }
  356. func (api *OAuthRefreshAPI) loadGrokDurableAccountAfterPersist(parent context.Context, cacheKey string, accountID int64) (*Account, error) {
  357. cleanupParent := context.Background()
  358. if parent != nil {
  359. cleanupParent = context.WithoutCancel(parent)
  360. }
  361. ctx, cancel := context.WithTimeout(cleanupParent, defaultRefreshPostPersistCleanupTimeout)
  362. defer cancel()
  363. // A successful rotation can revoke the access token still cached from the
  364. // pre-rotation credential document. Trigger deletion at the commit boundary,
  365. // even if the attempt/parent context was canceled immediately after CAS.
  366. if api.tokenCache != nil {
  367. if err := api.tokenCache.DeleteAccessToken(ctx, cacheKey); err != nil {
  368. slog.Warn("oauth_refresh_post_persist_cache_delete_failed",
  369. "account_id", accountID,
  370. "cache_key", cacheKey,
  371. "error", err,
  372. )
  373. }
  374. }
  375. return api.accountRepo.GetByID(ctx, accountID)
  376. }
  377. // isInvalidGrantError 检查错误是否为 invalid_grant
  378. func isInvalidGrantError(err error) bool {
  379. return err != nil && strings.Contains(strings.ToLower(err.Error()), "invalid_grant")
  380. }
  381. // tryRecoverFromRefreshRace 在 invalid_grant 错误后尝试竞争恢复
  382. // 重新读取 DB,如果 refresh_token 已改变(说明另一个 worker 成功刷新),则返回更新后的 account
  383. func (api *OAuthRefreshAPI) tryRecoverFromRefreshRace(ctx context.Context, usedAccount *Account) (*Account, bool) {
  384. if api.accountRepo == nil {
  385. return nil, false
  386. }
  387. reReadAccount, err := api.accountRepo.GetByID(ctx, usedAccount.ID)
  388. if err != nil || reReadAccount == nil {
  389. return nil, false
  390. }
  391. usedRT := usedAccount.GetCredential("refresh_token")
  392. currentRT := reReadAccount.GetCredential("refresh_token")
  393. if usedRT == "" || currentRT == "" {
  394. return nil, false
  395. }
  396. // refresh_token 不同 → 另一个 worker 已成功刷新
  397. if usedRT != currentRT {
  398. return reReadAccount, true
  399. }
  400. return nil, false
  401. }
  402. // MergeCredentials 将旧 credentials 中不存在于新 map 的字段保留到新 map 中
  403. func MergeCredentials(oldCreds, newCreds map[string]any) map[string]any {
  404. if newCreds == nil {
  405. newCreds = make(map[string]any)
  406. }
  407. for k, v := range oldCreds {
  408. if _, exists := newCreds[k]; !exists {
  409. newCreds[k] = v
  410. }
  411. }
  412. return newCreds
  413. }
  414. // BuildClaudeAccountCredentials 为 Claude 平台构建 OAuth credentials map
  415. // 消除 Claude 平台没有 BuildAccountCredentials 方法的问题
  416. func BuildClaudeAccountCredentials(tokenInfo *TokenInfo) map[string]any {
  417. creds := map[string]any{
  418. "access_token": tokenInfo.AccessToken,
  419. "token_type": tokenInfo.TokenType,
  420. "expires_in": strconv.FormatInt(tokenInfo.ExpiresIn, 10),
  421. "expires_at": strconv.FormatInt(tokenInfo.ExpiresAt, 10),
  422. }
  423. if tokenInfo.RefreshToken != "" {
  424. creds["refresh_token"] = tokenInfo.RefreshToken
  425. }
  426. if tokenInfo.Scope != "" {
  427. creds["scope"] = tokenInfo.Scope
  428. }
  429. return creds
  430. }