openai_oauth_service.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. package service
  2. import (
  3. "context"
  4. "crypto/subtle"
  5. "log/slog"
  6. "net/http"
  7. "strings"
  8. "time"
  9. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  11. )
  12. // OpenAIOAuthService handles OpenAI OAuth authentication flows
  13. type OpenAIOAuthService struct {
  14. sessionStore *openai.SessionStore
  15. proxyRepo ProxyRepository
  16. oauthClient OpenAIOAuthClient
  17. privacyClientFactory PrivacyClientFactory // 用于调用 chatgpt.com/backend-api(ImpersonateChrome)
  18. }
  19. // NewOpenAIOAuthService creates a new OpenAI OAuth service
  20. func NewOpenAIOAuthService(proxyRepo ProxyRepository, oauthClient OpenAIOAuthClient) *OpenAIOAuthService {
  21. return &OpenAIOAuthService{
  22. sessionStore: openai.NewSessionStore(),
  23. proxyRepo: proxyRepo,
  24. oauthClient: oauthClient,
  25. }
  26. }
  27. // SetPrivacyClientFactory 注入 ImpersonateChrome 客户端工厂,
  28. // 用于调用 chatgpt.com/backend-api 获取账号信息(plan_type 等)。
  29. func (s *OpenAIOAuthService) SetPrivacyClientFactory(factory PrivacyClientFactory) {
  30. s.privacyClientFactory = factory
  31. }
  32. // OpenAIAuthURLResult contains the authorization URL and session info
  33. type OpenAIAuthURLResult struct {
  34. AuthURL string `json:"auth_url"`
  35. SessionID string `json:"session_id"`
  36. }
  37. // GenerateAuthURL generates an OpenAI OAuth authorization URL
  38. func (s *OpenAIOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64, redirectURI, platform string) (*OpenAIAuthURLResult, error) {
  39. // Generate PKCE values
  40. state, err := openai.GenerateState()
  41. if err != nil {
  42. return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_OAUTH_STATE_FAILED", "failed to generate state: %v", err)
  43. }
  44. codeVerifier, err := openai.GenerateCodeVerifier()
  45. if err != nil {
  46. return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_OAUTH_VERIFIER_FAILED", "failed to generate code verifier: %v", err)
  47. }
  48. codeChallenge := openai.GenerateCodeChallenge(codeVerifier)
  49. // Generate session ID
  50. sessionID, err := openai.GenerateSessionID()
  51. if err != nil {
  52. return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_OAUTH_SESSION_FAILED", "failed to generate session ID: %v", err)
  53. }
  54. // Get proxy URL if specified
  55. var proxyURL string
  56. if proxyID != nil {
  57. proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
  58. if err != nil {
  59. return nil, infraerrors.Newf(http.StatusBadRequest, "OPENAI_OAUTH_PROXY_NOT_FOUND", "proxy not found: %v", err)
  60. }
  61. if proxy != nil {
  62. proxyURL = proxy.URL()
  63. }
  64. }
  65. // Use default redirect URI if not specified
  66. if redirectURI == "" {
  67. redirectURI = openai.DefaultRedirectURI
  68. }
  69. normalizedPlatform := normalizeOpenAIOAuthPlatform(platform)
  70. clientID, _ := openai.OAuthClientConfigByPlatform(normalizedPlatform)
  71. // Store session
  72. session := &openai.OAuthSession{
  73. State: state,
  74. CodeVerifier: codeVerifier,
  75. ClientID: clientID,
  76. RedirectURI: redirectURI,
  77. ProxyURL: proxyURL,
  78. CreatedAt: time.Now(),
  79. }
  80. s.sessionStore.Set(sessionID, session)
  81. // Build authorization URL
  82. authURL := openai.BuildAuthorizationURLForPlatform(state, codeChallenge, redirectURI, normalizedPlatform)
  83. return &OpenAIAuthURLResult{
  84. AuthURL: authURL,
  85. SessionID: sessionID,
  86. }, nil
  87. }
  88. // OpenAIExchangeCodeInput represents the input for code exchange
  89. type OpenAIExchangeCodeInput struct {
  90. SessionID string
  91. Code string
  92. State string
  93. RedirectURI string
  94. ProxyID *int64
  95. }
  96. // OpenAITokenInfo represents the token information for OpenAI
  97. type OpenAITokenInfo struct {
  98. AccessToken string `json:"access_token"`
  99. RefreshToken string `json:"refresh_token"`
  100. IDToken string `json:"id_token,omitempty"`
  101. ExpiresIn int64 `json:"expires_in"`
  102. ExpiresAt int64 `json:"expires_at"`
  103. ClientID string `json:"client_id,omitempty"`
  104. AuthMode string `json:"auth_mode,omitempty"`
  105. Email string `json:"email,omitempty"`
  106. ChatGPTAccountID string `json:"chatgpt_account_id,omitempty"`
  107. ChatGPTUserID string `json:"chatgpt_user_id,omitempty"`
  108. ChatGPTAccountFedRAMP bool `json:"chatgpt_account_is_fedramp,omitempty"`
  109. OrganizationID string `json:"organization_id,omitempty"`
  110. PlanType string `json:"plan_type,omitempty"`
  111. SubscriptionExpiresAt string `json:"subscription_expires_at,omitempty"`
  112. PrivacyMode string `json:"privacy_mode,omitempty"`
  113. }
  114. // ExchangeCode exchanges authorization code for tokens
  115. func (s *OpenAIOAuthService) ExchangeCode(ctx context.Context, input *OpenAIExchangeCodeInput) (*OpenAITokenInfo, error) {
  116. // Get session
  117. session, ok := s.sessionStore.Get(input.SessionID)
  118. if !ok {
  119. return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_SESSION_NOT_FOUND", "session not found or expired")
  120. }
  121. if input.State == "" {
  122. return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_STATE_REQUIRED", "oauth state is required")
  123. }
  124. if subtle.ConstantTimeCompare([]byte(input.State), []byte(session.State)) != 1 {
  125. return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_INVALID_STATE", "invalid oauth state")
  126. }
  127. // Get proxy URL: prefer input.ProxyID, fallback to session.ProxyURL
  128. proxyURL := session.ProxyURL
  129. if input.ProxyID != nil {
  130. proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID)
  131. if err != nil {
  132. return nil, infraerrors.Newf(http.StatusBadRequest, "OPENAI_OAUTH_PROXY_NOT_FOUND", "proxy not found: %v", err)
  133. }
  134. if proxy != nil {
  135. proxyURL = proxy.URL()
  136. }
  137. }
  138. // Use redirect URI from session or input
  139. redirectURI := session.RedirectURI
  140. if input.RedirectURI != "" {
  141. redirectURI = input.RedirectURI
  142. }
  143. clientID := strings.TrimSpace(session.ClientID)
  144. if clientID == "" {
  145. clientID = openai.ClientID
  146. }
  147. // Exchange code for token
  148. tokenResp, err := s.oauthClient.ExchangeCode(ctx, input.Code, session.CodeVerifier, redirectURI, proxyURL, clientID)
  149. if err != nil {
  150. return nil, err
  151. }
  152. // Parse ID token to get user info
  153. var userInfo *openai.UserInfo
  154. if tokenResp.IDToken != "" {
  155. claims, parseErr := openai.ParseIDToken(tokenResp.IDToken)
  156. if parseErr != nil {
  157. slog.Warn("openai_oauth_id_token_parse_failed", "error", parseErr)
  158. } else {
  159. userInfo = claims.GetUserInfo()
  160. }
  161. }
  162. // Delete session after successful exchange
  163. s.sessionStore.Delete(input.SessionID)
  164. tokenInfo := &OpenAITokenInfo{
  165. AccessToken: tokenResp.AccessToken,
  166. RefreshToken: tokenResp.RefreshToken,
  167. IDToken: tokenResp.IDToken,
  168. ExpiresIn: int64(tokenResp.ExpiresIn),
  169. ExpiresAt: time.Now().Unix() + int64(tokenResp.ExpiresIn),
  170. ClientID: clientID,
  171. }
  172. if userInfo != nil {
  173. tokenInfo.Email = userInfo.Email
  174. tokenInfo.ChatGPTAccountID = userInfo.ChatGPTAccountID
  175. tokenInfo.ChatGPTUserID = userInfo.ChatGPTUserID
  176. tokenInfo.OrganizationID = userInfo.OrganizationID
  177. tokenInfo.PlanType = userInfo.PlanType
  178. }
  179. s.enrichTokenInfo(ctx, tokenInfo, proxyURL)
  180. return tokenInfo, nil
  181. }
  182. // RefreshToken refreshes an OpenAI OAuth token
  183. func (s *OpenAIOAuthService) RefreshToken(ctx context.Context, refreshToken string, proxyURL string) (*OpenAITokenInfo, error) {
  184. return s.RefreshTokenWithClientID(ctx, refreshToken, proxyURL, "")
  185. }
  186. // RefreshTokenWithClientID refreshes an OpenAI OAuth token with optional client_id.
  187. func (s *OpenAIOAuthService) RefreshTokenWithClientID(ctx context.Context, refreshToken string, proxyURL string, clientID string) (*OpenAITokenInfo, error) {
  188. tokenResp, err := s.oauthClient.RefreshTokenWithClientID(ctx, refreshToken, proxyURL, clientID)
  189. if err != nil {
  190. return nil, err
  191. }
  192. // Parse ID token to get user info
  193. var userInfo *openai.UserInfo
  194. if tokenResp.IDToken != "" {
  195. claims, parseErr := openai.ParseIDToken(tokenResp.IDToken)
  196. if parseErr != nil {
  197. slog.Warn("openai_oauth_id_token_parse_failed", "error", parseErr)
  198. } else {
  199. userInfo = claims.GetUserInfo()
  200. }
  201. }
  202. tokenInfo := &OpenAITokenInfo{
  203. AccessToken: tokenResp.AccessToken,
  204. RefreshToken: tokenResp.RefreshToken,
  205. IDToken: tokenResp.IDToken,
  206. ExpiresIn: int64(tokenResp.ExpiresIn),
  207. ExpiresAt: time.Now().Unix() + int64(tokenResp.ExpiresIn),
  208. }
  209. if trimmed := strings.TrimSpace(clientID); trimmed != "" {
  210. tokenInfo.ClientID = trimmed
  211. }
  212. if userInfo != nil {
  213. tokenInfo.Email = userInfo.Email
  214. tokenInfo.ChatGPTAccountID = userInfo.ChatGPTAccountID
  215. tokenInfo.ChatGPTUserID = userInfo.ChatGPTUserID
  216. tokenInfo.OrganizationID = userInfo.OrganizationID
  217. tokenInfo.PlanType = userInfo.PlanType
  218. }
  219. s.enrichTokenInfo(ctx, tokenInfo, proxyURL)
  220. return tokenInfo, nil
  221. }
  222. // enrichTokenInfo 通过 ChatGPT backend-api 补全 tokenInfo 并设置隐私(best-effort)。
  223. // 从 accounts/check 获取最新 plan_type、subscription_expires_at、email,
  224. // 然后尝试关闭训练数据共享。适用于所有获取/刷新 token 的路径。
  225. func (s *OpenAIOAuthService) enrichTokenInfo(ctx context.Context, tokenInfo *OpenAITokenInfo, proxyURL string) {
  226. if tokenInfo.AccessToken == "" || s.privacyClientFactory == nil {
  227. return
  228. }
  229. // 从 access_token JWT 中提取 orgID(poid),用于匹配正确的账号
  230. orgID := tokenInfo.OrganizationID
  231. if orgID == "" {
  232. if atClaims, err := openai.DecodeIDToken(tokenInfo.AccessToken); err == nil && atClaims.OpenAIAuth != nil {
  233. orgID = atClaims.OpenAIAuth.POID
  234. }
  235. }
  236. // accounts/check 命中的记录不属于个人账号时,必须改用个人订阅端点拿到期时间,
  237. // 否则会把 workspace 权益的 expires_at 当成个人订阅到期日展示。
  238. forcePersonalSubscriptionLookup := false
  239. if info := fetchChatGPTAccountInfo(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, orgID); info != nil {
  240. // chatgpt_plan_type from the ID token is the canonical personal-plan value.
  241. // accounts/check is a multi-account/workspace endpoint; inactive team or
  242. // business workspaces can otherwise overwrite Pro/Free with internal
  243. // workspace billing plan names such as self_serve_business_usage_based.
  244. appliedAccountInfoPlanType := shouldApplyChatGPTAccountInfoPlanType(tokenInfo.PlanType, info.PlanType)
  245. if appliedAccountInfoPlanType {
  246. tokenInfo.PlanType = info.PlanType
  247. }
  248. // plan_type 与 subscription_expires_at 必须描述同一份订阅。套餐取自
  249. // accounts/check 时,到期时间跟着取同一条记录;套餐保留了 JWT 里的个人值时,
  250. // 只有该记录确实就是个人账号才能用它的 entitlement.expires_at——poid 指向的
  251. // 默认 Personal workspace 与 chatgpt_account_id 可以是两个不同的标识,
  252. // 混用会显示成「个人 Pro + workspace 到期时间」。
  253. if info.SubscriptionExpiresAt != "" {
  254. if appliedAccountInfoPlanType || chatGPTAccountInfoBelongsToTokenAccount(tokenInfo, info) {
  255. tokenInfo.SubscriptionExpiresAt = info.SubscriptionExpiresAt
  256. } else {
  257. forcePersonalSubscriptionLookup = true
  258. }
  259. }
  260. if tokenInfo.Email == "" && info.Email != "" {
  261. tokenInfo.Email = info.Email
  262. }
  263. }
  264. if forcePersonalSubscriptionLookup || strings.TrimSpace(tokenInfo.SubscriptionExpiresAt) == "" {
  265. if expiresAt := fetchChatGPTSubscriptionExpiresAt(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, resolveChatGPTSubscriptionAccountID(tokenInfo, orgID)); expiresAt != "" {
  266. tokenInfo.SubscriptionExpiresAt = expiresAt
  267. }
  268. }
  269. // 尝试设置隐私(关闭训练数据共享),best-effort
  270. tokenInfo.PrivacyMode = disableOpenAITraining(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL)
  271. }
  272. func shouldApplyChatGPTAccountInfoPlanType(current, candidate string) bool {
  273. return strings.TrimSpace(candidate) != "" && strings.TrimSpace(current) == ""
  274. }
  275. // chatGPTAccountInfoBelongsToTokenAccount 判断 accounts/check 命中的那条记录是不是
  276. // token 自己的个人 ChatGPT 账号。两侧任一缺 ID 时无法区分,返回 true 保持既有行为。
  277. func chatGPTAccountInfoBelongsToTokenAccount(tokenInfo *OpenAITokenInfo, info *ChatGPTAccountInfo) bool {
  278. personalID := strings.TrimSpace(tokenInfo.ChatGPTAccountID)
  279. sourceID := strings.TrimSpace(info.AccountID)
  280. if personalID == "" || sourceID == "" {
  281. return true
  282. }
  283. return strings.EqualFold(personalID, sourceID)
  284. }
  285. func resolveChatGPTSubscriptionAccountID(tokenInfo *OpenAITokenInfo, orgID string) string {
  286. for _, candidate := range []string{
  287. tokenInfo.ChatGPTAccountID,
  288. tokenInfo.OrganizationID,
  289. orgID,
  290. } {
  291. if trimmed := strings.TrimSpace(candidate); trimmed != "" {
  292. return trimmed
  293. }
  294. }
  295. return ""
  296. }
  297. // RefreshAccountToken refreshes token for an OpenAI OAuth account
  298. func (s *OpenAIOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*OpenAITokenInfo, error) {
  299. if account.Platform != PlatformOpenAI {
  300. return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_INVALID_ACCOUNT", "account is not an OpenAI account")
  301. }
  302. if account.Type != AccountTypeOAuth {
  303. return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_INVALID_ACCOUNT_TYPE", "account is not an OAuth account")
  304. }
  305. var proxyURL string
  306. if account.ProxyID != nil && s.proxyRepo != nil {
  307. proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
  308. if err == nil && proxy != nil {
  309. proxyURL = proxy.URL()
  310. }
  311. }
  312. accessToken := account.GetCredential("access_token")
  313. if account.IsOpenAIPersonalAccessToken() {
  314. if accessToken == "" {
  315. return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_CODEX_PAT_REQUIRED", "access token is required")
  316. }
  317. return s.ValidateCodexPersonalAccessToken(ctx, accessToken, proxyURL)
  318. }
  319. refreshToken := account.GetCredential("refresh_token")
  320. if refreshToken == "" {
  321. if accessToken != "" {
  322. tokenInfo := &OpenAITokenInfo{
  323. AccessToken: accessToken,
  324. RefreshToken: "",
  325. IDToken: account.GetCredential("id_token"),
  326. ClientID: account.GetCredential("client_id"),
  327. Email: account.GetCredential("email"),
  328. ChatGPTAccountID: account.GetCredential("chatgpt_account_id"),
  329. ChatGPTUserID: account.GetCredential("chatgpt_user_id"),
  330. OrganizationID: account.GetCredential("organization_id"),
  331. PlanType: account.GetCredential("plan_type"),
  332. SubscriptionExpiresAt: account.GetCredential("subscription_expires_at"),
  333. }
  334. if expiresAt := account.GetCredentialAsTime("expires_at"); expiresAt != nil {
  335. tokenInfo.ExpiresAt = expiresAt.Unix()
  336. tokenInfo.ExpiresIn = int64(time.Until(*expiresAt).Seconds())
  337. }
  338. s.enrichTokenInfo(ctx, tokenInfo, proxyURL)
  339. return tokenInfo, nil
  340. }
  341. return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_NO_REFRESH_TOKEN", "no refresh token available")
  342. }
  343. clientID := account.GetCredential("client_id")
  344. return s.RefreshTokenWithClientID(ctx, refreshToken, proxyURL, clientID)
  345. }
  346. // BuildAccountCredentials builds credentials map from token info
  347. func (s *OpenAIOAuthService) BuildAccountCredentials(tokenInfo *OpenAITokenInfo) map[string]any {
  348. creds := map[string]any{
  349. "access_token": tokenInfo.AccessToken,
  350. }
  351. if tokenInfo.ExpiresAt > 0 {
  352. creds["expires_at"] = time.Unix(tokenInfo.ExpiresAt, 0).Format(time.RFC3339)
  353. }
  354. // 仅在刷新响应返回了新的 refresh_token 时才更新,防止用空值覆盖已有令牌
  355. if strings.TrimSpace(tokenInfo.RefreshToken) != "" {
  356. creds["refresh_token"] = tokenInfo.RefreshToken
  357. }
  358. if tokenInfo.IDToken != "" {
  359. creds["id_token"] = tokenInfo.IDToken
  360. }
  361. if tokenInfo.Email != "" {
  362. creds["email"] = tokenInfo.Email
  363. }
  364. if tokenInfo.ChatGPTAccountID != "" {
  365. creds["chatgpt_account_id"] = tokenInfo.ChatGPTAccountID
  366. }
  367. if tokenInfo.ChatGPTUserID != "" {
  368. creds["chatgpt_user_id"] = tokenInfo.ChatGPTUserID
  369. }
  370. if tokenInfo.OrganizationID != "" {
  371. creds["organization_id"] = tokenInfo.OrganizationID
  372. }
  373. if tokenInfo.PlanType != "" {
  374. creds["plan_type"] = tokenInfo.PlanType
  375. }
  376. if tokenInfo.SubscriptionExpiresAt != "" {
  377. creds["subscription_expires_at"] = tokenInfo.SubscriptionExpiresAt
  378. }
  379. if strings.TrimSpace(tokenInfo.ClientID) != "" {
  380. creds["client_id"] = strings.TrimSpace(tokenInfo.ClientID)
  381. }
  382. if tokenInfo.AuthMode == OpenAIAuthModePersonalAccessToken {
  383. creds[openAIAuthModeCredentialKey] = OpenAIAuthModePersonalAccessToken
  384. creds[openAIAuthModeLegacyCredentialKey] = "personal_access_token"
  385. creds["token_type"] = "Bearer"
  386. creds["chatgpt_account_is_fedramp"] = tokenInfo.ChatGPTAccountFedRAMP
  387. } else if tokenInfo.ChatGPTAccountFedRAMP {
  388. creds["chatgpt_account_is_fedramp"] = true
  389. }
  390. return NormalizeOpenAIPersonalAccessTokenCredentials(nil, tokenInfo, creds)
  391. }
  392. // Stop stops the session store cleanup goroutine
  393. func (s *OpenAIOAuthService) Stop() {
  394. s.sessionStore.Stop()
  395. }
  396. func normalizeOpenAIOAuthPlatform(platform string) string {
  397. return openai.OAuthPlatformOpenAI
  398. }