| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448 |
- package service
- import (
- "context"
- "crypto/subtle"
- "log/slog"
- "net/http"
- "strings"
- "time"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
- )
- // OpenAIOAuthService handles OpenAI OAuth authentication flows
- type OpenAIOAuthService struct {
- sessionStore *openai.SessionStore
- proxyRepo ProxyRepository
- oauthClient OpenAIOAuthClient
- privacyClientFactory PrivacyClientFactory // 用于调用 chatgpt.com/backend-api(ImpersonateChrome)
- }
- // NewOpenAIOAuthService creates a new OpenAI OAuth service
- func NewOpenAIOAuthService(proxyRepo ProxyRepository, oauthClient OpenAIOAuthClient) *OpenAIOAuthService {
- return &OpenAIOAuthService{
- sessionStore: openai.NewSessionStore(),
- proxyRepo: proxyRepo,
- oauthClient: oauthClient,
- }
- }
- // SetPrivacyClientFactory 注入 ImpersonateChrome 客户端工厂,
- // 用于调用 chatgpt.com/backend-api 获取账号信息(plan_type 等)。
- func (s *OpenAIOAuthService) SetPrivacyClientFactory(factory PrivacyClientFactory) {
- s.privacyClientFactory = factory
- }
- // OpenAIAuthURLResult contains the authorization URL and session info
- type OpenAIAuthURLResult struct {
- AuthURL string `json:"auth_url"`
- SessionID string `json:"session_id"`
- }
- // GenerateAuthURL generates an OpenAI OAuth authorization URL
- func (s *OpenAIOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64, redirectURI, platform string) (*OpenAIAuthURLResult, error) {
- // Generate PKCE values
- state, err := openai.GenerateState()
- if err != nil {
- return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_OAUTH_STATE_FAILED", "failed to generate state: %v", err)
- }
- codeVerifier, err := openai.GenerateCodeVerifier()
- if err != nil {
- return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_OAUTH_VERIFIER_FAILED", "failed to generate code verifier: %v", err)
- }
- codeChallenge := openai.GenerateCodeChallenge(codeVerifier)
- // Generate session ID
- sessionID, err := openai.GenerateSessionID()
- if err != nil {
- return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_OAUTH_SESSION_FAILED", "failed to generate session ID: %v", err)
- }
- // Get proxy URL if specified
- var proxyURL string
- if proxyID != nil {
- proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
- if err != nil {
- return nil, infraerrors.Newf(http.StatusBadRequest, "OPENAI_OAUTH_PROXY_NOT_FOUND", "proxy not found: %v", err)
- }
- if proxy != nil {
- proxyURL = proxy.URL()
- }
- }
- // Use default redirect URI if not specified
- if redirectURI == "" {
- redirectURI = openai.DefaultRedirectURI
- }
- normalizedPlatform := normalizeOpenAIOAuthPlatform(platform)
- clientID, _ := openai.OAuthClientConfigByPlatform(normalizedPlatform)
- // Store session
- session := &openai.OAuthSession{
- State: state,
- CodeVerifier: codeVerifier,
- ClientID: clientID,
- RedirectURI: redirectURI,
- ProxyURL: proxyURL,
- CreatedAt: time.Now(),
- }
- s.sessionStore.Set(sessionID, session)
- // Build authorization URL
- authURL := openai.BuildAuthorizationURLForPlatform(state, codeChallenge, redirectURI, normalizedPlatform)
- return &OpenAIAuthURLResult{
- AuthURL: authURL,
- SessionID: sessionID,
- }, nil
- }
- // OpenAIExchangeCodeInput represents the input for code exchange
- type OpenAIExchangeCodeInput struct {
- SessionID string
- Code string
- State string
- RedirectURI string
- ProxyID *int64
- }
- // OpenAITokenInfo represents the token information for OpenAI
- type OpenAITokenInfo struct {
- AccessToken string `json:"access_token"`
- RefreshToken string `json:"refresh_token"`
- IDToken string `json:"id_token,omitempty"`
- ExpiresIn int64 `json:"expires_in"`
- ExpiresAt int64 `json:"expires_at"`
- ClientID string `json:"client_id,omitempty"`
- AuthMode string `json:"auth_mode,omitempty"`
- Email string `json:"email,omitempty"`
- ChatGPTAccountID string `json:"chatgpt_account_id,omitempty"`
- ChatGPTUserID string `json:"chatgpt_user_id,omitempty"`
- ChatGPTAccountFedRAMP bool `json:"chatgpt_account_is_fedramp,omitempty"`
- OrganizationID string `json:"organization_id,omitempty"`
- PlanType string `json:"plan_type,omitempty"`
- SubscriptionExpiresAt string `json:"subscription_expires_at,omitempty"`
- PrivacyMode string `json:"privacy_mode,omitempty"`
- }
- // ExchangeCode exchanges authorization code for tokens
- func (s *OpenAIOAuthService) ExchangeCode(ctx context.Context, input *OpenAIExchangeCodeInput) (*OpenAITokenInfo, error) {
- // Get session
- session, ok := s.sessionStore.Get(input.SessionID)
- if !ok {
- return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_SESSION_NOT_FOUND", "session not found or expired")
- }
- if input.State == "" {
- return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_STATE_REQUIRED", "oauth state is required")
- }
- if subtle.ConstantTimeCompare([]byte(input.State), []byte(session.State)) != 1 {
- return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_INVALID_STATE", "invalid oauth state")
- }
- // Get proxy URL: prefer input.ProxyID, fallback to session.ProxyURL
- proxyURL := session.ProxyURL
- if input.ProxyID != nil {
- proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID)
- if err != nil {
- return nil, infraerrors.Newf(http.StatusBadRequest, "OPENAI_OAUTH_PROXY_NOT_FOUND", "proxy not found: %v", err)
- }
- if proxy != nil {
- proxyURL = proxy.URL()
- }
- }
- // Use redirect URI from session or input
- redirectURI := session.RedirectURI
- if input.RedirectURI != "" {
- redirectURI = input.RedirectURI
- }
- clientID := strings.TrimSpace(session.ClientID)
- if clientID == "" {
- clientID = openai.ClientID
- }
- // Exchange code for token
- tokenResp, err := s.oauthClient.ExchangeCode(ctx, input.Code, session.CodeVerifier, redirectURI, proxyURL, clientID)
- if err != nil {
- return nil, err
- }
- // Parse ID token to get user info
- var userInfo *openai.UserInfo
- if tokenResp.IDToken != "" {
- claims, parseErr := openai.ParseIDToken(tokenResp.IDToken)
- if parseErr != nil {
- slog.Warn("openai_oauth_id_token_parse_failed", "error", parseErr)
- } else {
- userInfo = claims.GetUserInfo()
- }
- }
- // Delete session after successful exchange
- s.sessionStore.Delete(input.SessionID)
- tokenInfo := &OpenAITokenInfo{
- AccessToken: tokenResp.AccessToken,
- RefreshToken: tokenResp.RefreshToken,
- IDToken: tokenResp.IDToken,
- ExpiresIn: int64(tokenResp.ExpiresIn),
- ExpiresAt: time.Now().Unix() + int64(tokenResp.ExpiresIn),
- ClientID: clientID,
- }
- if userInfo != nil {
- tokenInfo.Email = userInfo.Email
- tokenInfo.ChatGPTAccountID = userInfo.ChatGPTAccountID
- tokenInfo.ChatGPTUserID = userInfo.ChatGPTUserID
- tokenInfo.OrganizationID = userInfo.OrganizationID
- tokenInfo.PlanType = userInfo.PlanType
- }
- s.enrichTokenInfo(ctx, tokenInfo, proxyURL)
- return tokenInfo, nil
- }
- // RefreshToken refreshes an OpenAI OAuth token
- func (s *OpenAIOAuthService) RefreshToken(ctx context.Context, refreshToken string, proxyURL string) (*OpenAITokenInfo, error) {
- return s.RefreshTokenWithClientID(ctx, refreshToken, proxyURL, "")
- }
- // RefreshTokenWithClientID refreshes an OpenAI OAuth token with optional client_id.
- func (s *OpenAIOAuthService) RefreshTokenWithClientID(ctx context.Context, refreshToken string, proxyURL string, clientID string) (*OpenAITokenInfo, error) {
- tokenResp, err := s.oauthClient.RefreshTokenWithClientID(ctx, refreshToken, proxyURL, clientID)
- if err != nil {
- return nil, err
- }
- // Parse ID token to get user info
- var userInfo *openai.UserInfo
- if tokenResp.IDToken != "" {
- claims, parseErr := openai.ParseIDToken(tokenResp.IDToken)
- if parseErr != nil {
- slog.Warn("openai_oauth_id_token_parse_failed", "error", parseErr)
- } else {
- userInfo = claims.GetUserInfo()
- }
- }
- tokenInfo := &OpenAITokenInfo{
- AccessToken: tokenResp.AccessToken,
- RefreshToken: tokenResp.RefreshToken,
- IDToken: tokenResp.IDToken,
- ExpiresIn: int64(tokenResp.ExpiresIn),
- ExpiresAt: time.Now().Unix() + int64(tokenResp.ExpiresIn),
- }
- if trimmed := strings.TrimSpace(clientID); trimmed != "" {
- tokenInfo.ClientID = trimmed
- }
- if userInfo != nil {
- tokenInfo.Email = userInfo.Email
- tokenInfo.ChatGPTAccountID = userInfo.ChatGPTAccountID
- tokenInfo.ChatGPTUserID = userInfo.ChatGPTUserID
- tokenInfo.OrganizationID = userInfo.OrganizationID
- tokenInfo.PlanType = userInfo.PlanType
- }
- s.enrichTokenInfo(ctx, tokenInfo, proxyURL)
- return tokenInfo, nil
- }
- // enrichTokenInfo 通过 ChatGPT backend-api 补全 tokenInfo 并设置隐私(best-effort)。
- // 从 accounts/check 获取最新 plan_type、subscription_expires_at、email,
- // 然后尝试关闭训练数据共享。适用于所有获取/刷新 token 的路径。
- func (s *OpenAIOAuthService) enrichTokenInfo(ctx context.Context, tokenInfo *OpenAITokenInfo, proxyURL string) {
- if tokenInfo.AccessToken == "" || s.privacyClientFactory == nil {
- return
- }
- // 从 access_token JWT 中提取 orgID(poid),用于匹配正确的账号
- orgID := tokenInfo.OrganizationID
- if orgID == "" {
- if atClaims, err := openai.DecodeIDToken(tokenInfo.AccessToken); err == nil && atClaims.OpenAIAuth != nil {
- orgID = atClaims.OpenAIAuth.POID
- }
- }
- // accounts/check 命中的记录不属于个人账号时,必须改用个人订阅端点拿到期时间,
- // 否则会把 workspace 权益的 expires_at 当成个人订阅到期日展示。
- forcePersonalSubscriptionLookup := false
- if info := fetchChatGPTAccountInfo(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, orgID); info != nil {
- // chatgpt_plan_type from the ID token is the canonical personal-plan value.
- // accounts/check is a multi-account/workspace endpoint; inactive team or
- // business workspaces can otherwise overwrite Pro/Free with internal
- // workspace billing plan names such as self_serve_business_usage_based.
- appliedAccountInfoPlanType := shouldApplyChatGPTAccountInfoPlanType(tokenInfo.PlanType, info.PlanType)
- if appliedAccountInfoPlanType {
- tokenInfo.PlanType = info.PlanType
- }
- // plan_type 与 subscription_expires_at 必须描述同一份订阅。套餐取自
- // accounts/check 时,到期时间跟着取同一条记录;套餐保留了 JWT 里的个人值时,
- // 只有该记录确实就是个人账号才能用它的 entitlement.expires_at——poid 指向的
- // 默认 Personal workspace 与 chatgpt_account_id 可以是两个不同的标识,
- // 混用会显示成「个人 Pro + workspace 到期时间」。
- if info.SubscriptionExpiresAt != "" {
- if appliedAccountInfoPlanType || chatGPTAccountInfoBelongsToTokenAccount(tokenInfo, info) {
- tokenInfo.SubscriptionExpiresAt = info.SubscriptionExpiresAt
- } else {
- forcePersonalSubscriptionLookup = true
- }
- }
- if tokenInfo.Email == "" && info.Email != "" {
- tokenInfo.Email = info.Email
- }
- }
- if forcePersonalSubscriptionLookup || strings.TrimSpace(tokenInfo.SubscriptionExpiresAt) == "" {
- if expiresAt := fetchChatGPTSubscriptionExpiresAt(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, resolveChatGPTSubscriptionAccountID(tokenInfo, orgID)); expiresAt != "" {
- tokenInfo.SubscriptionExpiresAt = expiresAt
- }
- }
- // 尝试设置隐私(关闭训练数据共享),best-effort
- tokenInfo.PrivacyMode = disableOpenAITraining(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL)
- }
- func shouldApplyChatGPTAccountInfoPlanType(current, candidate string) bool {
- return strings.TrimSpace(candidate) != "" && strings.TrimSpace(current) == ""
- }
- // chatGPTAccountInfoBelongsToTokenAccount 判断 accounts/check 命中的那条记录是不是
- // token 自己的个人 ChatGPT 账号。两侧任一缺 ID 时无法区分,返回 true 保持既有行为。
- func chatGPTAccountInfoBelongsToTokenAccount(tokenInfo *OpenAITokenInfo, info *ChatGPTAccountInfo) bool {
- personalID := strings.TrimSpace(tokenInfo.ChatGPTAccountID)
- sourceID := strings.TrimSpace(info.AccountID)
- if personalID == "" || sourceID == "" {
- return true
- }
- return strings.EqualFold(personalID, sourceID)
- }
- func resolveChatGPTSubscriptionAccountID(tokenInfo *OpenAITokenInfo, orgID string) string {
- for _, candidate := range []string{
- tokenInfo.ChatGPTAccountID,
- tokenInfo.OrganizationID,
- orgID,
- } {
- if trimmed := strings.TrimSpace(candidate); trimmed != "" {
- return trimmed
- }
- }
- return ""
- }
- // RefreshAccountToken refreshes token for an OpenAI OAuth account
- func (s *OpenAIOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*OpenAITokenInfo, error) {
- if account.Platform != PlatformOpenAI {
- return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_INVALID_ACCOUNT", "account is not an OpenAI account")
- }
- if account.Type != AccountTypeOAuth {
- return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_INVALID_ACCOUNT_TYPE", "account is not an OAuth account")
- }
- var proxyURL string
- if account.ProxyID != nil && s.proxyRepo != nil {
- proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
- if err == nil && proxy != nil {
- proxyURL = proxy.URL()
- }
- }
- accessToken := account.GetCredential("access_token")
- if account.IsOpenAIPersonalAccessToken() {
- if accessToken == "" {
- return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_CODEX_PAT_REQUIRED", "access token is required")
- }
- return s.ValidateCodexPersonalAccessToken(ctx, accessToken, proxyURL)
- }
- refreshToken := account.GetCredential("refresh_token")
- if refreshToken == "" {
- if accessToken != "" {
- tokenInfo := &OpenAITokenInfo{
- AccessToken: accessToken,
- RefreshToken: "",
- IDToken: account.GetCredential("id_token"),
- ClientID: account.GetCredential("client_id"),
- Email: account.GetCredential("email"),
- ChatGPTAccountID: account.GetCredential("chatgpt_account_id"),
- ChatGPTUserID: account.GetCredential("chatgpt_user_id"),
- OrganizationID: account.GetCredential("organization_id"),
- PlanType: account.GetCredential("plan_type"),
- SubscriptionExpiresAt: account.GetCredential("subscription_expires_at"),
- }
- if expiresAt := account.GetCredentialAsTime("expires_at"); expiresAt != nil {
- tokenInfo.ExpiresAt = expiresAt.Unix()
- tokenInfo.ExpiresIn = int64(time.Until(*expiresAt).Seconds())
- }
- s.enrichTokenInfo(ctx, tokenInfo, proxyURL)
- return tokenInfo, nil
- }
- return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_NO_REFRESH_TOKEN", "no refresh token available")
- }
- clientID := account.GetCredential("client_id")
- return s.RefreshTokenWithClientID(ctx, refreshToken, proxyURL, clientID)
- }
- // BuildAccountCredentials builds credentials map from token info
- func (s *OpenAIOAuthService) BuildAccountCredentials(tokenInfo *OpenAITokenInfo) map[string]any {
- creds := map[string]any{
- "access_token": tokenInfo.AccessToken,
- }
- if tokenInfo.ExpiresAt > 0 {
- creds["expires_at"] = time.Unix(tokenInfo.ExpiresAt, 0).Format(time.RFC3339)
- }
- // 仅在刷新响应返回了新的 refresh_token 时才更新,防止用空值覆盖已有令牌
- if strings.TrimSpace(tokenInfo.RefreshToken) != "" {
- creds["refresh_token"] = tokenInfo.RefreshToken
- }
- if tokenInfo.IDToken != "" {
- creds["id_token"] = tokenInfo.IDToken
- }
- if tokenInfo.Email != "" {
- creds["email"] = tokenInfo.Email
- }
- if tokenInfo.ChatGPTAccountID != "" {
- creds["chatgpt_account_id"] = tokenInfo.ChatGPTAccountID
- }
- if tokenInfo.ChatGPTUserID != "" {
- creds["chatgpt_user_id"] = tokenInfo.ChatGPTUserID
- }
- if tokenInfo.OrganizationID != "" {
- creds["organization_id"] = tokenInfo.OrganizationID
- }
- if tokenInfo.PlanType != "" {
- creds["plan_type"] = tokenInfo.PlanType
- }
- if tokenInfo.SubscriptionExpiresAt != "" {
- creds["subscription_expires_at"] = tokenInfo.SubscriptionExpiresAt
- }
- if strings.TrimSpace(tokenInfo.ClientID) != "" {
- creds["client_id"] = strings.TrimSpace(tokenInfo.ClientID)
- }
- if tokenInfo.AuthMode == OpenAIAuthModePersonalAccessToken {
- creds[openAIAuthModeCredentialKey] = OpenAIAuthModePersonalAccessToken
- creds[openAIAuthModeLegacyCredentialKey] = "personal_access_token"
- creds["token_type"] = "Bearer"
- creds["chatgpt_account_is_fedramp"] = tokenInfo.ChatGPTAccountFedRAMP
- } else if tokenInfo.ChatGPTAccountFedRAMP {
- creds["chatgpt_account_is_fedramp"] = true
- }
- return NormalizeOpenAIPersonalAccessTokenCredentials(nil, tokenInfo, creds)
- }
- // Stop stops the session store cleanup goroutine
- func (s *OpenAIOAuthService) Stop() {
- s.sessionStore.Stop()
- }
- func normalizeOpenAIOAuthPlatform(platform string) string {
- return openai.OAuthPlatformOpenAI
- }
|