| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437 |
- package repository
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "os"
- "strings"
- "time"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- sharedhttp "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
- "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/Wei-Shaw/sub2api/internal/util/logredact"
- "github.com/imroc/req/v3"
- )
- type grokOAuthClient struct {
- tokenURL string
- }
- const (
- accountsBaseURL = "https://accounts.x.ai"
- loginRPCEndpoint = accountsBaseURL + "/api/rpc"
- turnstileWebsiteURL = accountsBaseURL
- turnstileWebsiteKey = "0x4AAAAAAAhr9JGVDZbrZOo0"
- yesCaptchaCreateTask = "https://api.yescaptcha.com/createTask"
- yesCaptchaGetResult = "https://api.yescaptcha.com/getTaskResult"
- )
- func NewGrokOAuthClient() service.GrokOAuthClient {
- // Fail closed: never fall back to an unvalidated EffectiveTokenURL (env can
- // point at an attacker host and steal code/refresh tokens).
- tokenURL, err := xai.ValidatedTokenURL()
- if err != nil || strings.TrimSpace(tokenURL) == "" {
- // Official allowlisted endpoint only — never EffectiveTokenURL() (raw env).
- tokenURL = xai.DefaultTokenURL
- }
- return &grokOAuthClient{tokenURL: tokenURL}
- }
- func (c *grokOAuthClient) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*xai.TokenResponse, error) {
- client, err := createGrokReqClient(proxyURL)
- if err != nil {
- return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CLIENT_INIT_FAILED", "create HTTP client: %v", err)
- }
- clientID = strings.TrimSpace(clientID)
- if clientID == "" {
- clientID = xai.EffectiveClientID()
- }
- formData := url.Values{}
- formData.Set("grant_type", "authorization_code")
- formData.Set("client_id", clientID)
- formData.Set("code", code)
- formData.Set("redirect_uri", xai.EffectiveRedirectURI(redirectURI))
- formData.Set("code_verifier", codeVerifier)
- var tokenResp xai.TokenResponse
- resp, err := client.R().
- SetContext(ctx).
- SetHeader("User-Agent", "sub2api-grok-oauth/1.0").
- SetFormDataFromValues(formData).
- SetSuccessResult(&tokenResp).
- Post(c.tokenURL)
- if err != nil {
- return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_REQUEST_FAILED", "request failed: %v", err)
- }
- if !resp.IsSuccessState() {
- return nil, grokOAuthStatusError("GROK_OAUTH_TOKEN_EXCHANGE_FAILED", "token exchange failed", resp)
- }
- return &tokenResp, nil
- }
- func (c *grokOAuthClient) RefreshToken(ctx context.Context, refreshToken, proxyURL, clientID string) (*xai.TokenResponse, error) {
- client, err := createGrokReqClient(proxyURL)
- if err != nil {
- return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CLIENT_INIT_FAILED", "create HTTP client: %v", err)
- }
- clientID = strings.TrimSpace(clientID)
- if clientID == "" {
- clientID = xai.EffectiveClientID()
- }
- formData := url.Values{}
- formData.Set("grant_type", "refresh_token")
- formData.Set("client_id", clientID)
- formData.Set("refresh_token", refreshToken)
- var tokenResp xai.TokenResponse
- resp, err := client.R().
- SetContext(ctx).
- SetHeader("User-Agent", "sub2api-grok-oauth/1.0").
- SetFormDataFromValues(formData).
- SetSuccessResult(&tokenResp).
- Post(c.tokenURL)
- if err != nil {
- return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_REQUEST_FAILED", "request failed: %v", err)
- }
- if !resp.IsSuccessState() {
- return nil, grokOAuthStatusError("GROK_OAUTH_TOKEN_REFRESH_FAILED", "token refresh failed", resp)
- }
- return &tokenResp, nil
- }
- // LoginWithPassword authenticates against accounts.x.ai and returns an ephemeral SSO cookie.
- // Password and SSO must never be written to account credentials or logs.
- func (c *grokOAuthClient) LoginWithPassword(ctx context.Context, email, password, proxyURL string) (*service.GrokPasswordLoginResult, error) {
- turnstileToken, err := solveTurnstile(ctx)
- if err != nil {
- return nil, err
- }
- httpClient, err := createGrokHTTPClient(proxyURL, true)
- if err != nil {
- return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CLIENT_INIT_FAILED", "create HTTP client: %v", err)
- }
- cookieSetterURL, err := createGrokPasswordSession(ctx, httpClient, strings.TrimSpace(email), password, turnstileToken)
- if err != nil {
- return nil, err
- }
- ssoToken, err := extractGrokSSOToken(ctx, httpClient, cookieSetterURL)
- if err != nil {
- return nil, err
- }
- return &service.GrokPasswordLoginResult{
- Email: strings.TrimSpace(email),
- SSOToken: ssoToken,
- }, nil
- }
- func (c *grokOAuthClient) ConvertSSOToBuild(ctx context.Context, ssoToken, proxyURL string) (*xai.TokenResponse, error) {
- client, err := createGrokSSOHTTPClient(proxyURL)
- if err != nil {
- return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_SSO_CLIENT_INIT_FAILED", "create HTTP client: %v", err)
- }
- requestCtx, cancel := context.WithTimeout(ctx, xai.SSOConversionTimeout)
- defer cancel()
- tokenResp, err := xai.ConvertSSOToBuild(requestCtx, ssoToken, &xai.SSODeviceOptions{HTTPClient: client})
- if err != nil {
- return nil, grokSSOConversionError(err)
- }
- return tokenResp, nil
- }
- func createGrokReqClient(proxyURL string) (*req.Client, error) {
- return getSharedReqClient(reqClientOptions{
- ProxyURL: proxyURL,
- Timeout: 60 * time.Second,
- })
- }
- func createGrokSSOHTTPClient(proxyURL string) (*http.Client, error) {
- client, err := sharedhttp.GetClient(sharedhttp.Options{
- ProxyURL: proxyURL,
- Timeout: xai.SSOConversionTimeout,
- ResponseHeaderTimeout: 30 * time.Second,
- })
- if err != nil {
- return nil, err
- }
- clone := *client
- clone.CheckRedirect = func(*http.Request, []*http.Request) error {
- return http.ErrUseLastResponse
- }
- return &clone, nil
- }
- func grokSSOConversionError(err error) error {
- if errors.Is(err, xai.ErrSSOUnauthorized) {
- return infraerrors.New(http.StatusUnauthorized, "GROK_SSO_UNAUTHORIZED", "Grok Web SSO cookie is invalid or expired")
- }
- if errors.Is(err, xai.ErrSSOAuthorizationDenied) {
- return infraerrors.New(http.StatusForbidden, "GROK_SSO_AUTHORIZATION_DENIED", "xAI device authorization was denied or expired")
- }
- var statusErr xai.SSOHTTPError
- if errors.As(err, &statusErr) {
- statusCode := http.StatusBadGateway
- if statusErr.Status == http.StatusForbidden {
- statusCode = http.StatusForbidden
- }
- return infraerrors.Newf(statusCode, "GROK_SSO_UPSTREAM_FAILED", "xAI SSO conversion failed: %v", err)
- }
- if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
- return infraerrors.Newf(http.StatusGatewayTimeout, "GROK_SSO_TIMEOUT", "xAI SSO conversion timed out: %v", err)
- }
- return infraerrors.Newf(http.StatusBadGateway, "GROK_SSO_CONVERSION_FAILED", "xAI SSO conversion failed: %v", err)
- }
- func grokOAuthStatusError(code, message string, resp *req.Response) error {
- statusCode := http.StatusBadGateway
- errorCode := code
- upstreamStatus := 0
- body := ""
- if resp != nil {
- upstreamStatus = resp.StatusCode
- body = logredact.RedactText(resp.String())
- if resp.StatusCode == http.StatusForbidden && grokOAuthHasExplicitEntitlementDenial(body) {
- statusCode = http.StatusForbidden
- errorCode = "GROK_OAUTH_ENTITLEMENT_DENIED"
- }
- }
- return infraerrors.Newf(statusCode, errorCode, "%s: status %d, body: %s", message, upstreamStatus, body)
- }
- func grokOAuthHasExplicitEntitlementDenial(body string) bool {
- lower := strings.ToLower(body)
- // Billing exhaustion is recoverable. xAI may include a generic
- // access_denied code alongside the quota message, so it must win over the
- // entitlement marker during token refresh.
- for _, phrase := range []string{
- "spending limit",
- "run out of credits",
- "out of credits",
- "credits exhausted",
- "included free usage",
- } {
- if strings.Contains(lower, phrase) {
- return false
- }
- }
- compact := strings.NewReplacer(" ", "", "\n", "", "\r", "", "\t", "").Replace(lower)
- for _, field := range []string{"error", "code", "reason"} {
- for _, value := range []string{"access_denied", "entitlement_denied", "subscription_required", "no_active_subscription"} {
- if strings.Contains(compact, `"`+field+`":"`+value+`"`) {
- return true
- }
- }
- }
- return strings.Contains(lower, "entitlement denied") ||
- strings.Contains(lower, "subscription required") ||
- strings.Contains(lower, "no active grok subscription")
- }
- func createGrokHTTPClient(proxyURL string, noRedirect bool) (*http.Client, error) {
- transport := &http.Transport{}
- if strings.TrimSpace(proxyURL) != "" {
- parsed, err := url.Parse(proxyURL)
- if err != nil {
- return nil, err
- }
- transport.Proxy = http.ProxyURL(parsed)
- }
- client := &http.Client{Timeout: 120 * time.Second, Transport: transport}
- if noRedirect {
- client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
- return http.ErrUseLastResponse
- }
- }
- return client, nil
- }
- func solveTurnstile(ctx context.Context) (string, error) {
- clientKey := strings.TrimSpace(os.Getenv("YESCAPTCHA_CLIENT_KEY"))
- if clientKey == "" {
- clientKey = strings.TrimSpace(os.Getenv("YESCAPTCHA_API_KEY"))
- }
- if clientKey == "" {
- return "", infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_CAPTCHA_KEY_REQUIRED", "yescaptcha client key is required for Grok password authorization")
- }
- createBody, err := json.Marshal(map[string]any{
- "clientKey": clientKey,
- "task": map[string]any{
- "type": "TurnstileTaskProxyless",
- "websiteURL": turnstileWebsiteURL,
- "websiteKey": turnstileWebsiteKey,
- },
- })
- if err != nil {
- return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_CAPTCHA_FAILED", "encode captcha create request failed: %v", err)
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, yesCaptchaCreateTask, bytes.NewReader(createBody))
- if err != nil {
- return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "build captcha create request failed: %v", err)
- }
- req.Header.Set("Content-Type", "application/json")
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "create captcha task failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
- var createResp struct {
- ErrorID int `json:"errorId"`
- TaskID string `json:"taskId"`
- ErrorDescription string `json:"errorDescription"`
- }
- if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil {
- return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "decode captcha create response failed: %v", err)
- }
- if createResp.ErrorID != 0 || strings.TrimSpace(createResp.TaskID) == "" {
- return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "captcha create failed: %s", createResp.ErrorDescription)
- }
- deadline := time.Now().Add(90 * time.Second)
- for time.Now().Before(deadline) {
- select {
- case <-ctx.Done():
- return "", ctx.Err()
- case <-time.After(5 * time.Second):
- }
- body, err := json.Marshal(map[string]any{"clientKey": clientKey, "taskId": createResp.TaskID})
- if err != nil {
- return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_CAPTCHA_FAILED", "encode captcha poll request failed: %v", err)
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, yesCaptchaGetResult, bytes.NewReader(body))
- if err != nil {
- continue
- }
- req.Header.Set("Content-Type", "application/json")
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- continue
- }
- var pollResp struct {
- ErrorID int `json:"errorId"`
- Status string `json:"status"`
- ErrorDescription string `json:"errorDescription"`
- Solution struct {
- Token string `json:"token"`
- } `json:"solution"`
- }
- err = json.NewDecoder(resp.Body).Decode(&pollResp)
- _ = resp.Body.Close()
- if err != nil {
- continue
- }
- if pollResp.ErrorID != 0 {
- return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "captcha poll failed: %s", pollResp.ErrorDescription)
- }
- if pollResp.Status == "ready" && strings.TrimSpace(pollResp.Solution.Token) != "" {
- return pollResp.Solution.Token, nil
- }
- }
- return "", infraerrors.New(http.StatusGatewayTimeout, "GROK_OAUTH_CAPTCHA_TIMEOUT", "captcha solve timed out")
- }
- func createGrokPasswordSession(ctx context.Context, client *http.Client, email, password, turnstileToken string) (string, error) {
- payload, err := json.Marshal(map[string]any{
- "rpc": "createSession",
- "req": map[string]any{
- "createSessionRequest": map[string]any{
- "credentials": map[string]any{
- "case": "emailAndPassword",
- "value": map[string]any{
- "email": email,
- "clearTextPassword": password,
- },
- },
- },
- "turnstileToken": turnstileToken,
- },
- })
- if err != nil {
- return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "encode password login request failed: %v", err)
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, loginRPCEndpoint, bytes.NewReader(payload))
- if err != nil {
- return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "build password login request failed: %v", err)
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Origin", accountsBaseURL)
- req.Header.Set("Referer", accountsBaseURL+"/sign-in?redirect=grok-com&email=true")
- req.Header.Set("User-Agent", "Mozilla/5.0")
- req.Header.Set("Accept", "*/*")
- resp, err := client.Do(req)
- if err != nil {
- return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login request failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
- body, _ := io.ReadAll(resp.Body)
- if resp.StatusCode != http.StatusOK {
- return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login returned status %d: %s", resp.StatusCode, logredact.RedactText(string(body)))
- }
- var loginResp struct {
- CookieSetterURL string `json:"cookieSetterUrl"`
- Error string `json:"error"`
- }
- if err := json.Unmarshal(body, &loginResp); err != nil {
- return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "decode password login response failed: %v", err)
- }
- if strings.TrimSpace(loginResp.Error) != "" {
- return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login error: %s", logredact.RedactText(loginResp.Error))
- }
- if strings.TrimSpace(loginResp.CookieSetterURL) == "" {
- return "", infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login did not return cookieSetterUrl")
- }
- return loginResp.CookieSetterURL, nil
- }
- func extractGrokSSOToken(ctx context.Context, client *http.Client, cookieSetterURL string) (string, error) {
- safeURL, err := validateGrokCookieSetterURL(cookieSetterURL)
- if err != nil {
- return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "invalid cookie setter url: %v", err)
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, safeURL.String(), nil)
- if err != nil {
- return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "build cookie setter request: %v", err)
- }
- req.Header.Set("User-Agent", "Mozilla/5.0")
- req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
- req.Header.Set("Referer", accountsBaseURL+"/")
- resp, err := client.Do(req)
- if err != nil {
- return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "follow cookie setter url failed: %v", err)
- }
- defer func() { _ = resp.Body.Close() }()
- for _, cookie := range resp.Header.Values("Set-Cookie") {
- if token, ok := strings.CutPrefix(cookie, "sso="); ok {
- if idx := strings.Index(token, ";"); idx > 0 {
- token = token[:idx]
- }
- return strings.TrimSpace(token), nil
- }
- }
- return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "no sso cookie found in response (status=%d)", resp.StatusCode)
- }
- func validateGrokCookieSetterURL(rawURL string) (*url.URL, error) {
- parsed, err := url.Parse(strings.TrimSpace(rawURL))
- if err != nil {
- return nil, err
- }
- if parsed.Scheme != "https" || !strings.EqualFold(parsed.Hostname(), "accounts.x.ai") {
- return nil, fmt.Errorf("url must use https://accounts.x.ai")
- }
- if parsed.User != nil || parsed.Port() != "" || parsed.Fragment != "" || parsed.Opaque != "" {
- return nil, fmt.Errorf("url contains disallowed authority or fragment components")
- }
- return parsed, nil
- }
|