Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
438 lines
16 KiB
Go
438 lines
16 KiB
Go
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
|
|
}
|