grok_oauth_client.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. package repository
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "net/http"
  10. "net/url"
  11. "os"
  12. "strings"
  13. "time"
  14. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  15. sharedhttp "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
  16. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  17. "github.com/Wei-Shaw/sub2api/internal/service"
  18. "github.com/Wei-Shaw/sub2api/internal/util/logredact"
  19. "github.com/imroc/req/v3"
  20. )
  21. type grokOAuthClient struct {
  22. tokenURL string
  23. }
  24. const (
  25. accountsBaseURL = "https://accounts.x.ai"
  26. loginRPCEndpoint = accountsBaseURL + "/api/rpc"
  27. turnstileWebsiteURL = accountsBaseURL
  28. turnstileWebsiteKey = "0x4AAAAAAAhr9JGVDZbrZOo0"
  29. yesCaptchaCreateTask = "https://api.yescaptcha.com/createTask"
  30. yesCaptchaGetResult = "https://api.yescaptcha.com/getTaskResult"
  31. )
  32. func NewGrokOAuthClient() service.GrokOAuthClient {
  33. // Fail closed: never fall back to an unvalidated EffectiveTokenURL (env can
  34. // point at an attacker host and steal code/refresh tokens).
  35. tokenURL, err := xai.ValidatedTokenURL()
  36. if err != nil || strings.TrimSpace(tokenURL) == "" {
  37. // Official allowlisted endpoint only — never EffectiveTokenURL() (raw env).
  38. tokenURL = xai.DefaultTokenURL
  39. }
  40. return &grokOAuthClient{tokenURL: tokenURL}
  41. }
  42. func (c *grokOAuthClient) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*xai.TokenResponse, error) {
  43. client, err := createGrokReqClient(proxyURL)
  44. if err != nil {
  45. return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CLIENT_INIT_FAILED", "create HTTP client: %v", err)
  46. }
  47. clientID = strings.TrimSpace(clientID)
  48. if clientID == "" {
  49. clientID = xai.EffectiveClientID()
  50. }
  51. formData := url.Values{}
  52. formData.Set("grant_type", "authorization_code")
  53. formData.Set("client_id", clientID)
  54. formData.Set("code", code)
  55. formData.Set("redirect_uri", xai.EffectiveRedirectURI(redirectURI))
  56. formData.Set("code_verifier", codeVerifier)
  57. var tokenResp xai.TokenResponse
  58. resp, err := client.R().
  59. SetContext(ctx).
  60. SetHeader("User-Agent", "sub2api-grok-oauth/1.0").
  61. SetFormDataFromValues(formData).
  62. SetSuccessResult(&tokenResp).
  63. Post(c.tokenURL)
  64. if err != nil {
  65. return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_REQUEST_FAILED", "request failed: %v", err)
  66. }
  67. if !resp.IsSuccessState() {
  68. return nil, grokOAuthStatusError("GROK_OAUTH_TOKEN_EXCHANGE_FAILED", "token exchange failed", resp)
  69. }
  70. return &tokenResp, nil
  71. }
  72. func (c *grokOAuthClient) RefreshToken(ctx context.Context, refreshToken, proxyURL, clientID string) (*xai.TokenResponse, error) {
  73. client, err := createGrokReqClient(proxyURL)
  74. if err != nil {
  75. return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CLIENT_INIT_FAILED", "create HTTP client: %v", err)
  76. }
  77. clientID = strings.TrimSpace(clientID)
  78. if clientID == "" {
  79. clientID = xai.EffectiveClientID()
  80. }
  81. formData := url.Values{}
  82. formData.Set("grant_type", "refresh_token")
  83. formData.Set("client_id", clientID)
  84. formData.Set("refresh_token", refreshToken)
  85. var tokenResp xai.TokenResponse
  86. resp, err := client.R().
  87. SetContext(ctx).
  88. SetHeader("User-Agent", "sub2api-grok-oauth/1.0").
  89. SetFormDataFromValues(formData).
  90. SetSuccessResult(&tokenResp).
  91. Post(c.tokenURL)
  92. if err != nil {
  93. return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_REQUEST_FAILED", "request failed: %v", err)
  94. }
  95. if !resp.IsSuccessState() {
  96. return nil, grokOAuthStatusError("GROK_OAUTH_TOKEN_REFRESH_FAILED", "token refresh failed", resp)
  97. }
  98. return &tokenResp, nil
  99. }
  100. // LoginWithPassword authenticates against accounts.x.ai and returns an ephemeral SSO cookie.
  101. // Password and SSO must never be written to account credentials or logs.
  102. func (c *grokOAuthClient) LoginWithPassword(ctx context.Context, email, password, proxyURL string) (*service.GrokPasswordLoginResult, error) {
  103. turnstileToken, err := solveTurnstile(ctx)
  104. if err != nil {
  105. return nil, err
  106. }
  107. httpClient, err := createGrokHTTPClient(proxyURL, true)
  108. if err != nil {
  109. return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CLIENT_INIT_FAILED", "create HTTP client: %v", err)
  110. }
  111. cookieSetterURL, err := createGrokPasswordSession(ctx, httpClient, strings.TrimSpace(email), password, turnstileToken)
  112. if err != nil {
  113. return nil, err
  114. }
  115. ssoToken, err := extractGrokSSOToken(ctx, httpClient, cookieSetterURL)
  116. if err != nil {
  117. return nil, err
  118. }
  119. return &service.GrokPasswordLoginResult{
  120. Email: strings.TrimSpace(email),
  121. SSOToken: ssoToken,
  122. }, nil
  123. }
  124. func (c *grokOAuthClient) ConvertSSOToBuild(ctx context.Context, ssoToken, proxyURL string) (*xai.TokenResponse, error) {
  125. client, err := createGrokSSOHTTPClient(proxyURL)
  126. if err != nil {
  127. return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_SSO_CLIENT_INIT_FAILED", "create HTTP client: %v", err)
  128. }
  129. requestCtx, cancel := context.WithTimeout(ctx, xai.SSOConversionTimeout)
  130. defer cancel()
  131. tokenResp, err := xai.ConvertSSOToBuild(requestCtx, ssoToken, &xai.SSODeviceOptions{HTTPClient: client})
  132. if err != nil {
  133. return nil, grokSSOConversionError(err)
  134. }
  135. return tokenResp, nil
  136. }
  137. func createGrokReqClient(proxyURL string) (*req.Client, error) {
  138. return getSharedReqClient(reqClientOptions{
  139. ProxyURL: proxyURL,
  140. Timeout: 60 * time.Second,
  141. })
  142. }
  143. func createGrokSSOHTTPClient(proxyURL string) (*http.Client, error) {
  144. client, err := sharedhttp.GetClient(sharedhttp.Options{
  145. ProxyURL: proxyURL,
  146. Timeout: xai.SSOConversionTimeout,
  147. ResponseHeaderTimeout: 30 * time.Second,
  148. })
  149. if err != nil {
  150. return nil, err
  151. }
  152. clone := *client
  153. clone.CheckRedirect = func(*http.Request, []*http.Request) error {
  154. return http.ErrUseLastResponse
  155. }
  156. return &clone, nil
  157. }
  158. func grokSSOConversionError(err error) error {
  159. if errors.Is(err, xai.ErrSSOUnauthorized) {
  160. return infraerrors.New(http.StatusUnauthorized, "GROK_SSO_UNAUTHORIZED", "Grok Web SSO cookie is invalid or expired")
  161. }
  162. if errors.Is(err, xai.ErrSSOAuthorizationDenied) {
  163. return infraerrors.New(http.StatusForbidden, "GROK_SSO_AUTHORIZATION_DENIED", "xAI device authorization was denied or expired")
  164. }
  165. var statusErr xai.SSOHTTPError
  166. if errors.As(err, &statusErr) {
  167. statusCode := http.StatusBadGateway
  168. if statusErr.Status == http.StatusForbidden {
  169. statusCode = http.StatusForbidden
  170. }
  171. return infraerrors.Newf(statusCode, "GROK_SSO_UPSTREAM_FAILED", "xAI SSO conversion failed: %v", err)
  172. }
  173. if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
  174. return infraerrors.Newf(http.StatusGatewayTimeout, "GROK_SSO_TIMEOUT", "xAI SSO conversion timed out: %v", err)
  175. }
  176. return infraerrors.Newf(http.StatusBadGateway, "GROK_SSO_CONVERSION_FAILED", "xAI SSO conversion failed: %v", err)
  177. }
  178. func grokOAuthStatusError(code, message string, resp *req.Response) error {
  179. statusCode := http.StatusBadGateway
  180. errorCode := code
  181. upstreamStatus := 0
  182. body := ""
  183. if resp != nil {
  184. upstreamStatus = resp.StatusCode
  185. body = logredact.RedactText(resp.String())
  186. if resp.StatusCode == http.StatusForbidden && grokOAuthHasExplicitEntitlementDenial(body) {
  187. statusCode = http.StatusForbidden
  188. errorCode = "GROK_OAUTH_ENTITLEMENT_DENIED"
  189. }
  190. }
  191. return infraerrors.Newf(statusCode, errorCode, "%s: status %d, body: %s", message, upstreamStatus, body)
  192. }
  193. func grokOAuthHasExplicitEntitlementDenial(body string) bool {
  194. lower := strings.ToLower(body)
  195. // Billing exhaustion is recoverable. xAI may include a generic
  196. // access_denied code alongside the quota message, so it must win over the
  197. // entitlement marker during token refresh.
  198. for _, phrase := range []string{
  199. "spending limit",
  200. "run out of credits",
  201. "out of credits",
  202. "credits exhausted",
  203. "included free usage",
  204. } {
  205. if strings.Contains(lower, phrase) {
  206. return false
  207. }
  208. }
  209. compact := strings.NewReplacer(" ", "", "\n", "", "\r", "", "\t", "").Replace(lower)
  210. for _, field := range []string{"error", "code", "reason"} {
  211. for _, value := range []string{"access_denied", "entitlement_denied", "subscription_required", "no_active_subscription"} {
  212. if strings.Contains(compact, `"`+field+`":"`+value+`"`) {
  213. return true
  214. }
  215. }
  216. }
  217. return strings.Contains(lower, "entitlement denied") ||
  218. strings.Contains(lower, "subscription required") ||
  219. strings.Contains(lower, "no active grok subscription")
  220. }
  221. func createGrokHTTPClient(proxyURL string, noRedirect bool) (*http.Client, error) {
  222. transport := &http.Transport{}
  223. if strings.TrimSpace(proxyURL) != "" {
  224. parsed, err := url.Parse(proxyURL)
  225. if err != nil {
  226. return nil, err
  227. }
  228. transport.Proxy = http.ProxyURL(parsed)
  229. }
  230. client := &http.Client{Timeout: 120 * time.Second, Transport: transport}
  231. if noRedirect {
  232. client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
  233. return http.ErrUseLastResponse
  234. }
  235. }
  236. return client, nil
  237. }
  238. func solveTurnstile(ctx context.Context) (string, error) {
  239. clientKey := strings.TrimSpace(os.Getenv("YESCAPTCHA_CLIENT_KEY"))
  240. if clientKey == "" {
  241. clientKey = strings.TrimSpace(os.Getenv("YESCAPTCHA_API_KEY"))
  242. }
  243. if clientKey == "" {
  244. return "", infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_CAPTCHA_KEY_REQUIRED", "yescaptcha client key is required for Grok password authorization")
  245. }
  246. createBody, err := json.Marshal(map[string]any{
  247. "clientKey": clientKey,
  248. "task": map[string]any{
  249. "type": "TurnstileTaskProxyless",
  250. "websiteURL": turnstileWebsiteURL,
  251. "websiteKey": turnstileWebsiteKey,
  252. },
  253. })
  254. if err != nil {
  255. return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_CAPTCHA_FAILED", "encode captcha create request failed: %v", err)
  256. }
  257. req, err := http.NewRequestWithContext(ctx, http.MethodPost, yesCaptchaCreateTask, bytes.NewReader(createBody))
  258. if err != nil {
  259. return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "build captcha create request failed: %v", err)
  260. }
  261. req.Header.Set("Content-Type", "application/json")
  262. resp, err := http.DefaultClient.Do(req)
  263. if err != nil {
  264. return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "create captcha task failed: %v", err)
  265. }
  266. defer func() { _ = resp.Body.Close() }()
  267. var createResp struct {
  268. ErrorID int `json:"errorId"`
  269. TaskID string `json:"taskId"`
  270. ErrorDescription string `json:"errorDescription"`
  271. }
  272. if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil {
  273. return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "decode captcha create response failed: %v", err)
  274. }
  275. if createResp.ErrorID != 0 || strings.TrimSpace(createResp.TaskID) == "" {
  276. return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "captcha create failed: %s", createResp.ErrorDescription)
  277. }
  278. deadline := time.Now().Add(90 * time.Second)
  279. for time.Now().Before(deadline) {
  280. select {
  281. case <-ctx.Done():
  282. return "", ctx.Err()
  283. case <-time.After(5 * time.Second):
  284. }
  285. body, err := json.Marshal(map[string]any{"clientKey": clientKey, "taskId": createResp.TaskID})
  286. if err != nil {
  287. return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_CAPTCHA_FAILED", "encode captcha poll request failed: %v", err)
  288. }
  289. req, err := http.NewRequestWithContext(ctx, http.MethodPost, yesCaptchaGetResult, bytes.NewReader(body))
  290. if err != nil {
  291. continue
  292. }
  293. req.Header.Set("Content-Type", "application/json")
  294. resp, err := http.DefaultClient.Do(req)
  295. if err != nil {
  296. continue
  297. }
  298. var pollResp struct {
  299. ErrorID int `json:"errorId"`
  300. Status string `json:"status"`
  301. ErrorDescription string `json:"errorDescription"`
  302. Solution struct {
  303. Token string `json:"token"`
  304. } `json:"solution"`
  305. }
  306. err = json.NewDecoder(resp.Body).Decode(&pollResp)
  307. _ = resp.Body.Close()
  308. if err != nil {
  309. continue
  310. }
  311. if pollResp.ErrorID != 0 {
  312. return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "captcha poll failed: %s", pollResp.ErrorDescription)
  313. }
  314. if pollResp.Status == "ready" && strings.TrimSpace(pollResp.Solution.Token) != "" {
  315. return pollResp.Solution.Token, nil
  316. }
  317. }
  318. return "", infraerrors.New(http.StatusGatewayTimeout, "GROK_OAUTH_CAPTCHA_TIMEOUT", "captcha solve timed out")
  319. }
  320. func createGrokPasswordSession(ctx context.Context, client *http.Client, email, password, turnstileToken string) (string, error) {
  321. payload, err := json.Marshal(map[string]any{
  322. "rpc": "createSession",
  323. "req": map[string]any{
  324. "createSessionRequest": map[string]any{
  325. "credentials": map[string]any{
  326. "case": "emailAndPassword",
  327. "value": map[string]any{
  328. "email": email,
  329. "clearTextPassword": password,
  330. },
  331. },
  332. },
  333. "turnstileToken": turnstileToken,
  334. },
  335. })
  336. if err != nil {
  337. return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "encode password login request failed: %v", err)
  338. }
  339. req, err := http.NewRequestWithContext(ctx, http.MethodPost, loginRPCEndpoint, bytes.NewReader(payload))
  340. if err != nil {
  341. return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "build password login request failed: %v", err)
  342. }
  343. req.Header.Set("Content-Type", "application/json")
  344. req.Header.Set("Origin", accountsBaseURL)
  345. req.Header.Set("Referer", accountsBaseURL+"/sign-in?redirect=grok-com&email=true")
  346. req.Header.Set("User-Agent", "Mozilla/5.0")
  347. req.Header.Set("Accept", "*/*")
  348. resp, err := client.Do(req)
  349. if err != nil {
  350. return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login request failed: %v", err)
  351. }
  352. defer func() { _ = resp.Body.Close() }()
  353. body, _ := io.ReadAll(resp.Body)
  354. if resp.StatusCode != http.StatusOK {
  355. return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login returned status %d: %s", resp.StatusCode, logredact.RedactText(string(body)))
  356. }
  357. var loginResp struct {
  358. CookieSetterURL string `json:"cookieSetterUrl"`
  359. Error string `json:"error"`
  360. }
  361. if err := json.Unmarshal(body, &loginResp); err != nil {
  362. return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "decode password login response failed: %v", err)
  363. }
  364. if strings.TrimSpace(loginResp.Error) != "" {
  365. return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login error: %s", logredact.RedactText(loginResp.Error))
  366. }
  367. if strings.TrimSpace(loginResp.CookieSetterURL) == "" {
  368. return "", infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login did not return cookieSetterUrl")
  369. }
  370. return loginResp.CookieSetterURL, nil
  371. }
  372. func extractGrokSSOToken(ctx context.Context, client *http.Client, cookieSetterURL string) (string, error) {
  373. safeURL, err := validateGrokCookieSetterURL(cookieSetterURL)
  374. if err != nil {
  375. return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "invalid cookie setter url: %v", err)
  376. }
  377. req, err := http.NewRequestWithContext(ctx, http.MethodGet, safeURL.String(), nil)
  378. if err != nil {
  379. return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "build cookie setter request: %v", err)
  380. }
  381. req.Header.Set("User-Agent", "Mozilla/5.0")
  382. req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
  383. req.Header.Set("Referer", accountsBaseURL+"/")
  384. resp, err := client.Do(req)
  385. if err != nil {
  386. return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "follow cookie setter url failed: %v", err)
  387. }
  388. defer func() { _ = resp.Body.Close() }()
  389. for _, cookie := range resp.Header.Values("Set-Cookie") {
  390. if token, ok := strings.CutPrefix(cookie, "sso="); ok {
  391. if idx := strings.Index(token, ";"); idx > 0 {
  392. token = token[:idx]
  393. }
  394. return strings.TrimSpace(token), nil
  395. }
  396. }
  397. return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "no sso cookie found in response (status=%d)", resp.StatusCode)
  398. }
  399. func validateGrokCookieSetterURL(rawURL string) (*url.URL, error) {
  400. parsed, err := url.Parse(strings.TrimSpace(rawURL))
  401. if err != nil {
  402. return nil, err
  403. }
  404. if parsed.Scheme != "https" || !strings.EqualFold(parsed.Hostname(), "accounts.x.ai") {
  405. return nil, fmt.Errorf("url must use https://accounts.x.ai")
  406. }
  407. if parsed.User != nil || parsed.Port() != "" || parsed.Fragment != "" || parsed.Opaque != "" {
  408. return nil, fmt.Errorf("url contains disallowed authority or fragment components")
  409. }
  410. return parsed, nil
  411. }