openai_codex_pat_service.go 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "io"
  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/httpclient"
  11. )
  12. const openAICodexPATWhoamiURLDefault = "https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami"
  13. var openAICodexPATWhoamiURL = openAICodexPATWhoamiURLDefault
  14. var openAIPersonalAccessTokenOAuthCredentialKeys = [...]string{
  15. "refresh_token",
  16. "id_token",
  17. "expires_at",
  18. "expires_in",
  19. "client_id",
  20. }
  21. type openAICodexPATWhoamiResponse struct {
  22. Email string `json:"email"`
  23. ChatGPTUserID string `json:"chatgpt_user_id"`
  24. ChatGPTAccountID string `json:"chatgpt_account_id"`
  25. ChatGPTPlanType string `json:"chatgpt_plan_type"`
  26. ChatGPTAccountIsFedRAMP *bool `json:"chatgpt_account_is_fedramp"`
  27. }
  28. // ValidateCodexPersonalAccessToken validates a Codex at-* token using the same
  29. // first-class PAT endpoint used by the Codex client.
  30. func (s *OpenAIOAuthService) ValidateCodexPersonalAccessToken(ctx context.Context, accessToken, proxyURL string) (*OpenAITokenInfo, error) {
  31. accessToken = strings.TrimSpace(accessToken)
  32. if accessToken == "" {
  33. return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_CODEX_PAT_REQUIRED", "access token is required")
  34. }
  35. if !strings.HasPrefix(accessToken, "at-") {
  36. return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_CODEX_PAT_INVALID_PREFIX", "Codex personal access token must start with at-")
  37. }
  38. client, err := httpclient.GetClient(httpclient.Options{
  39. ProxyURL: proxyURL,
  40. Timeout: 20 * time.Second,
  41. ResponseHeaderTimeout: 15 * time.Second,
  42. })
  43. if err != nil {
  44. return nil, infraerrors.Newf(http.StatusBadRequest, "OPENAI_CODEX_PAT_PROXY_INVALID", "invalid proxy configuration: %v", err)
  45. }
  46. req, err := http.NewRequestWithContext(ctx, http.MethodGet, openAICodexPATWhoamiURL, nil)
  47. if err != nil {
  48. return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_PAT_REQUEST_FAILED", "failed to build validation request: %v", err)
  49. }
  50. req.Header.Set("authorization", "Bearer "+accessToken)
  51. req.Header.Set("accept", "application/json")
  52. ApplyCodexCanonicalAuthIdentity(req.Header)
  53. resp, err := client.Do(req)
  54. if err != nil {
  55. return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_PAT_VALIDATE_FAILED", "failed to validate Codex personal access token: %v", err)
  56. }
  57. defer func() { _ = resp.Body.Close() }()
  58. if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
  59. return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_CODEX_PAT_INVALID", "Codex personal access token is invalid or expired")
  60. }
  61. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  62. body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
  63. message := strings.TrimSpace(string(body))
  64. if message == "" {
  65. message = resp.Status
  66. }
  67. return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_PAT_VALIDATE_FAILED", "Codex personal access token validation failed: %s", message)
  68. }
  69. var whoami openAICodexPATWhoamiResponse
  70. if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&whoami); err != nil {
  71. return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_PAT_RESPONSE_INVALID", "invalid Codex personal access token validation response: %v", err)
  72. }
  73. if err := validateOpenAICodexPATWhoami(whoami); err != nil {
  74. return nil, err
  75. }
  76. return &OpenAITokenInfo{
  77. AccessToken: accessToken,
  78. AuthMode: OpenAIAuthModePersonalAccessToken,
  79. Email: strings.TrimSpace(whoami.Email),
  80. ChatGPTAccountID: strings.TrimSpace(whoami.ChatGPTAccountID),
  81. ChatGPTUserID: strings.TrimSpace(whoami.ChatGPTUserID),
  82. ChatGPTAccountFedRAMP: *whoami.ChatGPTAccountIsFedRAMP,
  83. PlanType: strings.TrimSpace(whoami.ChatGPTPlanType),
  84. }, nil
  85. }
  86. func validateOpenAICodexPATWhoami(whoami openAICodexPATWhoamiResponse) error {
  87. required := map[string]string{
  88. "email": whoami.Email,
  89. "chatgpt_user_id": whoami.ChatGPTUserID,
  90. "chatgpt_account_id": whoami.ChatGPTAccountID,
  91. "chatgpt_plan_type": whoami.ChatGPTPlanType,
  92. }
  93. for key, value := range required {
  94. if strings.TrimSpace(value) == "" {
  95. return infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_PAT_RESPONSE_INVALID", "Codex personal access token validation response is missing %s", key)
  96. }
  97. }
  98. if whoami.ChatGPTAccountIsFedRAMP == nil {
  99. return infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_PAT_RESPONSE_INVALID", "Codex personal access token validation response is missing chatgpt_account_is_fedramp")
  100. }
  101. return nil
  102. }
  103. // NormalizeOpenAIPersonalAccessTokenCredentials removes OAuth-only credential
  104. // fields from Codex personal access token accounts while preserving local
  105. // routing, mapping, quota, and metadata fields.
  106. func NormalizeOpenAIPersonalAccessTokenCredentials(account *Account, tokenInfo *OpenAITokenInfo, credentials map[string]any) map[string]any {
  107. if credentials == nil || !isOpenAIPersonalAccessTokenCredentialSet(account, tokenInfo, credentials) {
  108. return credentials
  109. }
  110. for _, key := range openAIPersonalAccessTokenOAuthCredentialKeys {
  111. delete(credentials, key)
  112. }
  113. credentials[openAIAuthModeCredentialKey] = OpenAIAuthModePersonalAccessToken
  114. credentials[openAIAuthModeLegacyCredentialKey] = "personal_access_token"
  115. credentials["token_type"] = "Bearer"
  116. return credentials
  117. }
  118. func isOpenAIPersonalAccessTokenCredentialSet(account *Account, tokenInfo *OpenAITokenInfo, credentials map[string]any) bool {
  119. if tokenInfo != nil && isOpenAIPersonalAccessTokenAuthMode(tokenInfo.AuthMode) {
  120. return true
  121. }
  122. if account != nil && account.IsOpenAIPersonalAccessToken() {
  123. return true
  124. }
  125. return isOpenAIPersonalAccessTokenAuthMode(openAICredentialString(credentials[openAIAuthModeCredentialKey])) ||
  126. isOpenAIPersonalAccessTokenAuthMode(openAICredentialString(credentials[openAIAuthModeLegacyCredentialKey]))
  127. }
  128. func openAICredentialString(value any) string {
  129. switch v := value.(type) {
  130. case string:
  131. return strings.TrimSpace(v)
  132. default:
  133. return ""
  134. }
  135. }