package xai import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" "golang.org/x/sync/singleflight" ) // XAIAuth performs xAI OAuth discovery, device-code login, and refresh. type XAIAuth struct { httpClient *http.Client } var xaiRefreshGroup singleflight.Group // NewXAIAuth creates an xAI OAuth helper using config proxy settings. func NewXAIAuth(cfg *config.Config) *XAIAuth { return NewXAIAuthWithProxyURL(cfg, "") } // NewXAIAuthWithProxyURL creates an xAI OAuth helper with an explicit proxy URL. func NewXAIAuthWithProxyURL(cfg *config.Config, proxyURL string) *XAIAuth { effectiveProxyURL := strings.TrimSpace(proxyURL) var sdkCfg config.SDKConfig if cfg != nil { sdkCfg = cfg.SDKConfig if effectiveProxyURL == "" { effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL) } } sdkCfg.ProxyURL = effectiveProxyURL return &XAIAuth{httpClient: util.SetProxy(&sdkCfg, &http.Client{Timeout: httpClientTimeout})} } // ValidateOAuthEndpoint validates an endpoint returned by xAI discovery. func ValidateOAuthEndpoint(rawURL string, field string) (string, error) { rawURL = strings.TrimSpace(rawURL) if rawURL == "" { return "", fmt.Errorf("xai discovery %s is empty", field) } parsed, err := url.Parse(rawURL) if err != nil { return "", fmt.Errorf("xai discovery %s is invalid: %w", field, err) } if parsed.Scheme != "https" { return "", fmt.Errorf("xai discovery %s must use https: %q", field, rawURL) } host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) if host != "x.ai" && !strings.HasSuffix(host, ".x.ai") { return "", fmt.Errorf("xai discovery %s host %q is not on x.ai", field, host) } return rawURL, nil } // Discover resolves xAI OAuth endpoints through OIDC discovery. func (a *XAIAuth) Discover(ctx context.Context) (*Discovery, error) { if ctx == nil { ctx = context.Background() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, DiscoveryURL, nil) if err != nil { return nil, fmt.Errorf("xai discovery: create request: %w", err) } req.Header.Set("Accept", "application/json") resp, err := a.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("xai discovery: request failed: %w", err) } defer func() { if errClose := resp.Body.Close(); errClose != nil { log.Errorf("xai discovery: close response body error: %v", errClose) } }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("xai discovery: read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("xai discovery failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var payload struct { DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` } if err = json.Unmarshal(body, &payload); err != nil { return nil, fmt.Errorf("xai discovery: parse response: %w", err) } deviceAuthorizationEndpoint, err := ValidateOAuthEndpoint(payload.DeviceAuthorizationEndpoint, "device_authorization_endpoint") if err != nil { return nil, err } tokenEndpoint, err := ValidateOAuthEndpoint(payload.TokenEndpoint, "token_endpoint") if err != nil { return nil, err } return &Discovery{ DeviceAuthorizationEndpoint: deviceAuthorizationEndpoint, TokenEndpoint: tokenEndpoint, }, nil } // StartDeviceFlow requests a device code from xAI. func (a *XAIAuth) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) { discovery, errDiscover := a.Discover(ctx) if errDiscover != nil { return nil, errDiscover } return a.RequestDeviceCode(ctx, discovery.DeviceAuthorizationEndpoint, discovery.TokenEndpoint) } // RequestDeviceCode requests a device authorization code from the given endpoint. func (a *XAIAuth) RequestDeviceCode(ctx context.Context, deviceAuthorizationEndpoint, tokenEndpoint string) (*DeviceCodeResponse, error) { if ctx == nil { ctx = context.Background() } deviceAuthorizationEndpoint = strings.TrimSpace(deviceAuthorizationEndpoint) if deviceAuthorizationEndpoint == "" { return nil, fmt.Errorf("xai device code: device authorization endpoint is required") } form := url.Values{ "client_id": {ClientID}, "scope": {Scope}, } req, err := http.NewRequestWithContext(ctx, http.MethodPost, deviceAuthorizationEndpoint, strings.NewReader(form.Encode())) if err != nil { return nil, fmt.Errorf("xai device code: create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := a.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("xai device code request failed: %w", err) } defer func() { if errClose := resp.Body.Close(); errClose != nil { log.Errorf("xai device code: close response body error: %v", errClose) } }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("xai device code: read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("xai device code request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var deviceCode DeviceCodeResponse if err = json.Unmarshal(body, &deviceCode); err != nil { return nil, fmt.Errorf("xai device code: parse response: %w", err) } if strings.TrimSpace(deviceCode.DeviceCode) == "" { return nil, fmt.Errorf("xai device code: response missing device_code") } if strings.TrimSpace(deviceCode.UserCode) == "" { return nil, fmt.Errorf("xai device code: response missing user_code") } if strings.TrimSpace(deviceCode.VerificationURI) == "" && strings.TrimSpace(deviceCode.VerificationURIComplete) == "" { return nil, fmt.Errorf("xai device code: response missing verification URI") } deviceCode.TokenEndpoint = strings.TrimSpace(tokenEndpoint) return &deviceCode, nil } // WaitForAuthorization polls until the user authorizes the device code and returns tokens. func (a *XAIAuth) WaitForAuthorization(ctx context.Context, deviceCode *DeviceCodeResponse) (*AuthBundle, error) { tokenData, err := a.PollForToken(ctx, deviceCode) if err != nil { return nil, err } tokenEndpoint := "" if deviceCode != nil { tokenEndpoint = strings.TrimSpace(deviceCode.TokenEndpoint) } return &AuthBundle{ TokenData: *tokenData, LastRefresh: time.Now().UTC().Format(time.RFC3339), BaseURL: DefaultAPIBaseURL, TokenEndpoint: tokenEndpoint, }, nil } // PollForToken polls the token endpoint until the user authorizes or the device code expires. func (a *XAIAuth) PollForToken(ctx context.Context, deviceCode *DeviceCodeResponse) (*TokenData, error) { if deviceCode == nil { return nil, fmt.Errorf("xai device code: response is nil") } if ctx == nil { ctx = context.Background() } tokenEndpoint := strings.TrimSpace(deviceCode.TokenEndpoint) if tokenEndpoint == "" { discovery, errDiscover := a.Discover(ctx) if errDiscover != nil { return nil, errDiscover } tokenEndpoint = discovery.TokenEndpoint } interval := time.Duration(deviceCode.Interval) * time.Second if interval < defaultPollInterval { interval = defaultPollInterval } deadline := time.Now().Add(MaxPollDuration) if deviceCode.ExpiresIn > 0 { codeDeadline := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second) if codeDeadline.Before(deadline) { deadline = codeDeadline } } // Poll immediately once, then wait between subsequent attempts. firstAttempt := true timer := time.NewTimer(0) defer timer.Stop() for { select { case <-ctx.Done(): return nil, fmt.Errorf("xai device code: context cancelled: %w", ctx.Err()) case <-timer.C: if !firstAttempt && time.Now().After(deadline) { return nil, fmt.Errorf("xai device code expired") } firstAttempt = false token, pollErr, nextInterval, shouldContinue := a.exchangeDeviceCode(ctx, tokenEndpoint, deviceCode.DeviceCode, interval) if token != nil { return token, nil } if !shouldContinue { return nil, pollErr } interval = nextInterval timer.Reset(interval) } } } // exchangeDeviceCode attempts to exchange a device code for tokens. // Returns (token, error, nextInterval, shouldContinue). func (a *XAIAuth) exchangeDeviceCode(ctx context.Context, tokenEndpoint, deviceCode string, interval time.Duration) (*TokenData, error, time.Duration, bool) { form := url.Values{ "grant_type": {DeviceCodeGrantType}, "device_code": {strings.TrimSpace(deviceCode)}, "client_id": {ClientID}, } req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(tokenEndpoint), strings.NewReader(form.Encode())) if err != nil { return nil, fmt.Errorf("xai device token: create request: %w", err), interval, false } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := a.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("xai device token request failed: %w", err), interval, false } defer func() { if errClose := resp.Body.Close(); errClose != nil { log.Errorf("xai device token: close response body error: %v", errClose) } }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("xai device token: read response: %w", err), interval, false } var payload struct { Error string `json:"error"` ErrorDescription string `json:"error_description"` AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` IDToken string `json:"id_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` } if err = json.Unmarshal(body, &payload); err != nil { return nil, fmt.Errorf("xai device token: parse response: %w", err), interval, false } if payload.Error != "" { switch payload.Error { case "authorization_pending": return nil, nil, interval, true case "slow_down": nextInterval := interval + defaultPollInterval return nil, nil, nextInterval, true case "expired_token": return nil, fmt.Errorf("xai device code expired"), interval, false case "access_denied": return nil, fmt.Errorf("xai device authorization denied"), interval, false default: desc := strings.TrimSpace(payload.ErrorDescription) if desc != "" { return nil, fmt.Errorf("xai device token error: %s: %s", payload.Error, desc), interval, false } return nil, fmt.Errorf("xai device token error: %s", payload.Error), interval, false } } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("xai device token request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))), interval, false } if strings.TrimSpace(payload.AccessToken) == "" { return nil, fmt.Errorf("xai device token response missing access_token"), interval, false } email, subject := parseJWTIdentity(payload.IDToken) return buildTokenData(payload.AccessToken, payload.RefreshToken, payload.IDToken, payload.TokenType, payload.ExpiresIn, email, subject), nil, interval, false } // RefreshTokens refreshes an xAI access token. func (a *XAIAuth) RefreshTokens(ctx context.Context, refreshToken, tokenEndpoint string) (*TokenData, error) { if strings.TrimSpace(refreshToken) == "" { return nil, fmt.Errorf("xai token refresh: refresh token is required") } if ctx == nil { ctx = context.Background() } refreshToken = strings.TrimSpace(refreshToken) if strings.TrimSpace(tokenEndpoint) == "" { discovery, errDiscover := a.Discover(ctx) if errDiscover != nil { return nil, errDiscover } tokenEndpoint = discovery.TokenEndpoint } tokenEndpoint = strings.TrimSpace(tokenEndpoint) result, err, _ := xaiRefreshGroup.Do(refreshToken, func() (interface{}, error) { return a.refreshTokensSingleFlight(context.WithoutCancel(ctx), refreshToken, tokenEndpoint) }) if err != nil { return nil, err } tokenData, ok := result.(*TokenData) if !ok || tokenData == nil { return nil, fmt.Errorf("xai token refresh failed: invalid single-flight result") } return tokenData, nil } func (a *XAIAuth) refreshTokensSingleFlight(ctx context.Context, refreshToken, tokenEndpoint string) (*TokenData, error) { form := url.Values{ "grant_type": {"refresh_token"}, "client_id": {ClientID}, "refresh_token": {refreshToken}, } return a.postTokenForm(ctx, tokenEndpoint, form) } func (a *XAIAuth) postTokenForm(ctx context.Context, tokenEndpoint string, form url.Values) (*TokenData, error) { if ctx == nil { ctx = context.Background() } req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(tokenEndpoint), strings.NewReader(form.Encode())) if err != nil { return nil, fmt.Errorf("xai token request: create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := a.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("xai token request failed: %w", err) } defer func() { if errClose := resp.Body.Close(); errClose != nil { log.Errorf("xai token request: close response body error: %v", errClose) } }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("xai token response: read body: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("xai token request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var payload struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` IDToken string `json:"id_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` } if err = json.Unmarshal(body, &payload); err != nil { return nil, fmt.Errorf("xai token response: parse body: %w", err) } if strings.TrimSpace(payload.AccessToken) == "" { return nil, fmt.Errorf("xai token response missing access_token") } email, subject := parseJWTIdentity(payload.IDToken) return buildTokenData(payload.AccessToken, payload.RefreshToken, payload.IDToken, payload.TokenType, payload.ExpiresIn, email, subject), nil } // CreateTokenStorage converts an auth bundle into persistable storage. func (a *XAIAuth) CreateTokenStorage(bundle *AuthBundle) *TokenStorage { if bundle == nil { return nil } return &TokenStorage{ Type: "xai", AccessToken: bundle.TokenData.AccessToken, RefreshToken: bundle.TokenData.RefreshToken, IDToken: bundle.TokenData.IDToken, TokenType: bundle.TokenData.TokenType, ExpiresIn: bundle.TokenData.ExpiresIn, Expire: bundle.TokenData.Expire, LastRefresh: bundle.LastRefresh, Email: strings.TrimSpace(bundle.TokenData.Email), Subject: bundle.TokenData.Subject, BaseURL: firstNonEmpty(bundle.BaseURL, DefaultAPIBaseURL), RedirectURI: bundle.RedirectURI, TokenEndpoint: bundle.TokenEndpoint, AuthKind: "oauth", } } func buildTokenData(accessToken, refreshToken, idToken, tokenType string, expiresIn int, email, subject string) *TokenData { tokenData := &TokenData{ AccessToken: strings.TrimSpace(accessToken), RefreshToken: strings.TrimSpace(refreshToken), IDToken: strings.TrimSpace(idToken), TokenType: strings.TrimSpace(tokenType), ExpiresIn: expiresIn, Email: email, Subject: subject, } if expiresIn > 0 { tokenData.Expire = time.Now().Add(time.Duration(expiresIn) * time.Second).UTC().Format(time.RFC3339) } return tokenData } func parseJWTIdentity(token string) (email string, subject string) { parts := strings.Split(token, ".") if len(parts) < 2 { return "", "" } payload := parts[1] payload += strings.Repeat("=", (4-len(payload)%4)%4) raw, err := base64.URLEncoding.DecodeString(payload) if err != nil { return "", "" } var claims map[string]any if err = json.Unmarshal(raw, &claims); err != nil { return "", "" } if v, ok := claims["email"].(string); ok { email = strings.TrimSpace(v) } if v, ok := claims["sub"].(string); ok { subject = strings.TrimSpace(v) } return email, subject } func firstNonEmpty(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed } } return "" }