Files
sub2api/backend/internal/repository/grok_oauth_client.go
T
jinfeijie bot ad4bf5c60d feat(grok): 支持 Web SSO 批量导入并转换为 Build OAuth
新增 Grok Web SSO → xAI Device Flow → Grok Build OAuth 导入链路,
支持管理员批量粘贴 SSO key 创建 OAuth 账号。

- 后端:ConvertSSOToBuild、ConvertFromSSO、POST /admin/grok/sso-to-oauth
- 批量:3 worker 并发,失败跳过并汇总 created/failed,worker panic recover
- 无 refresh_token 时写入 expires_at 并强制 auto_pause_on_expired
- 前端:SSO Cookie 导入入口、动态超时、中英文案、部分成功不关弹窗
- 测试:pkg/service/handler/前端超时单测;本地 Docker 真实 SSO e2e 通过
2026-07-14 01:09:07 +08:00

167 lines
5.5 KiB
Go

package repository
import (
"context"
"errors"
"net/http"
"net/url"
"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
}
func NewGrokOAuthClient() service.GrokOAuthClient {
return &grokOAuthClient{tokenURL: xai.EffectiveTokenURL()}
}
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
}
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
if resp != nil && resp.StatusCode == http.StatusForbidden {
statusCode = http.StatusForbidden
errorCode = "GROK_OAUTH_ENTITLEMENT_DENIED"
}
body := ""
if resp != nil {
upstreamStatus = resp.StatusCode
body = logredact.RedactText(resp.String())
}
return infraerrors.Newf(statusCode, errorCode, "%s: status %d, body: %s", message, upstreamStatus, body)
}