From d0930c4bdb2925bc83503cb358b5f0fc1beefe27 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Fri, 7 Aug 2026 14:13:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(grok):=20=E5=AE=8C=E5=96=84=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E4=B8=8ESSO=E6=8E=88=E6=9D=83=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/wire_gen.go | 2 +- backend/internal/config/config.go | 4 +++ backend/internal/config/config_test.go | 1 + .../handler/admin/grok_oauth_handler.go | 4 +++ .../handler/admin/grok_oauth_handler_test.go | 20 ++++++++++++- backend/internal/server/routes/admin.go | 1 + .../internal/service/grok_oauth_service.go | 25 +++++++++++++++-- .../service/grok_oauth_service_test.go | 17 ++++++++++- backend/internal/service/wire.go | 6 +++- deploy/config.example.yaml | 4 +++ frontend/src/api/admin/grok.ts | 20 +++++++++++-- .../account/OAuthAuthorizationFlow.vue | 28 +++++++++++++++++-- 12 files changed, 122 insertions(+), 10 deletions(-) diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 2369e09049..91cf74dc23 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -149,7 +149,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory) openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI) grokOAuthClient := repository.NewGrokOAuthClient() - grokOAuthService := service.NewGrokOAuthService(proxyRepository, grokOAuthClient) + grokOAuthService := service.ProvideGrokOAuthService(proxyRepository, grokOAuthClient, configConfig) grokTokenProvider := service.ProvideGrokTokenProvider(accountRepository, geminiTokenCache, grokOAuthService, oAuthRefreshAPI, tempUnschedCache) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, grokTokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository) geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2d9a95e256..ad9fe6c021 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1038,6 +1038,9 @@ type GatewayConfig struct { // - free_quota_window_hours: local usage rolling window length in hours. // - free_quota_stats_cache_seconds: bound hot-path aggregate query frequency (0 disables cache). type GatewayGrokConfig struct { + // PasswordAuthEnabled gates the experimental admin-only password flow. + // It is disabled by default because captcha solving uses an external service. + PasswordAuthEnabled bool `mapstructure:"password_auth_enabled"` // FreeQuotaSoftGateEnabled enables a local rolling-window scheduling guard // for explicitly free Grok OAuth accounts only. FreeQuotaSoftGateEnabled bool `mapstructure:"free_quota_soft_gate_enabled"` @@ -2339,6 +2342,7 @@ func setDefaults() { // Grok free-tier local soft gate (scheduler-only; admin QueryQuota does not use this). // Enabled by default because free detection requires an explicit free tier marker. viper.SetDefault("gateway.grok.free_quota_soft_gate_enabled", true) + viper.SetDefault("gateway.grok.password_auth_enabled", false) viper.SetDefault("gateway.grok.free_quota_token_limit", int64(2_000_000)) viper.SetDefault("gateway.grok.free_quota_soft_gate_percent", 95) viper.SetDefault("gateway.grok.free_quota_window_hours", 24) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index d560947176..f6a4891f80 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -543,6 +543,7 @@ func TestLoadDefaultGrokFreeQuotaSoftGate(t *testing.T) { cfg, err := Load() require.NoError(t, err) + require.False(t, cfg.Gateway.Grok.PasswordAuthEnabled) require.True(t, cfg.Gateway.Grok.FreeQuotaSoftGateEnabled) require.Equal(t, int64(2_000_000), cfg.Gateway.Grok.FreeQuotaTokenLimit) require.Equal(t, 95, cfg.Gateway.Grok.FreeQuotaSoftGatePercent) diff --git a/backend/internal/handler/admin/grok_oauth_handler.go b/backend/internal/handler/admin/grok_oauth_handler.go index 05853a9445..64bcbf9115 100644 --- a/backend/internal/handler/admin/grok_oauth_handler.go +++ b/backend/internal/handler/admin/grok_oauth_handler.go @@ -47,6 +47,10 @@ type GrokGenerateAuthURLRequest struct { RedirectURI string `json:"redirect_uri"` } +func (h *GrokOAuthHandler) GetCapabilities(c *gin.Context) { + response.Success(c, h.grokOAuthService.GetCapabilities()) +} + func (h *GrokOAuthHandler) GenerateAuthURL(c *gin.Context) { var req GrokGenerateAuthURLRequest if err := c.ShouldBindJSON(&req); err != nil { diff --git a/backend/internal/handler/admin/grok_oauth_handler_test.go b/backend/internal/handler/admin/grok_oauth_handler_test.go index e5a464b633..1cad90d4fd 100644 --- a/backend/internal/handler/admin/grok_oauth_handler_test.go +++ b/backend/internal/handler/admin/grok_oauth_handler_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -235,7 +236,9 @@ func TestGrokOAuthHandlerAuthorizePasswordReturnsTokenInfoWithoutPassword(t *tes gin.SetMode(gin.TestMode) oauthClient := &grokOAuthHandlerClient{} - oauthService := service.NewGrokOAuthService(nil, oauthClient) + cfg := &config.Config{} + cfg.Gateway.Grok.PasswordAuthEnabled = true + oauthService := service.NewGrokOAuthService(nil, oauthClient, cfg) defer oauthService.Stop() handler := NewGrokOAuthHandler(oauthService, nil, nil, nil) @@ -254,6 +257,21 @@ func TestGrokOAuthHandlerAuthorizePasswordReturnsTokenInfoWithoutPassword(t *tes require.NotContains(t, rec.Body.String(), "sso-from-password") } +func TestGrokOAuthHandlerPasswordCapabilityDefaultsToDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + oauthService := service.NewGrokOAuthService(nil, &grokOAuthHandlerClient{}) + defer oauthService.Stop() + handler := NewGrokOAuthHandler(oauthService, nil, nil, nil) + + router := gin.New() + router.GET("/api/v1/admin/grok/oauth/capabilities", handler.GetCapabilities) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/oauth/capabilities", nil)) + + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), `"password_auth_enabled":false`) +} + func TestGrokSSOImportExpiryUsesTokenExpiryWithoutRefreshToken(t *testing.T) { tokenExpiry := time.Now().Add(6 * time.Hour).Unix() expiresAt, autoPause := grokSSOImportExpiry(nil, nil, &service.GrokTokenInfo{ diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index ca02023ed5..4af5f8f4c9 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -463,6 +463,7 @@ func registerAntigravityOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) func registerGrokOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { grok := admin.Group("/grok") { + grok.GET("/oauth/capabilities", h.Admin.GrokOAuth.GetCapabilities) grok.POST("/oauth/auth-url", h.Admin.GrokOAuth.GenerateAuthURL) grok.POST("/oauth/exchange-code", h.Admin.GrokOAuth.ExchangeCode) grok.POST("/oauth/refresh-token", h.Admin.GrokOAuth.RefreshToken) diff --git a/backend/internal/service/grok_oauth_service.go b/backend/internal/service/grok_oauth_service.go index ea88a474b5..3cd9e13227 100644 --- a/backend/internal/service/grok_oauth_service.go +++ b/backend/internal/service/grok_oauth_service.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) @@ -18,14 +19,31 @@ type GrokOAuthService struct { sessionStore *xai.SessionStore proxyRepo ProxyRepository oauthClient GrokOAuthClient + config *config.Config } -func NewGrokOAuthService(proxyRepo ProxyRepository, oauthClient GrokOAuthClient) *GrokOAuthService { - return &GrokOAuthService{ +func NewGrokOAuthService(proxyRepo ProxyRepository, oauthClient GrokOAuthClient, configs ...*config.Config) *GrokOAuthService { + service := &GrokOAuthService{ sessionStore: xai.NewSessionStore(), proxyRepo: proxyRepo, oauthClient: oauthClient, } + if len(configs) > 0 { + service.config = configs[0] + } + return service +} + +type GrokOAuthCapabilities struct { + PasswordAuthEnabled bool `json:"password_auth_enabled"` +} + +func (s *GrokOAuthService) GetCapabilities() GrokOAuthCapabilities { + return GrokOAuthCapabilities{PasswordAuthEnabled: s.passwordAuthEnabled()} +} + +func (s *GrokOAuthService) passwordAuthEnabled() bool { + return s.config != nil && s.config.Gateway.Grok.PasswordAuthEnabled } type GrokAuthURLResult struct { @@ -212,6 +230,9 @@ func (s *GrokOAuthService) ConvertFromSSO(ctx context.Context, ssoToken string, // AuthorizePassword logs in with email/password, converts the resulting SSO cookie // to Build OAuth, and returns OAuth tokens only. Password and raw SSO are never persisted. func (s *GrokOAuthService) AuthorizePassword(ctx context.Context, email, password string, proxyID *int64) (*GrokTokenInfo, error) { + if !s.passwordAuthEnabled() { + return nil, infraerrors.New(http.StatusForbidden, "GROK_OAUTH_PASSWORD_AUTH_DISABLED", "Grok password authorization is disabled") + } email = strings.TrimSpace(email) if email == "" { return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_EMAIL_REQUIRED", "email is required") diff --git a/backend/internal/service/grok_oauth_service_test.go b/backend/internal/service/grok_oauth_service_test.go index 79c2790887..1b48466731 100644 --- a/backend/internal/service/grok_oauth_service_test.go +++ b/backend/internal/service/grok_oauth_service_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/stretchr/testify/require" ) @@ -153,7 +154,9 @@ func TestGrokOAuthServiceAuthorizePasswordUsesLoginThenSSOAuthorize(t *testing.T ExpiresIn: 3600, }, } - svc := NewGrokOAuthService(nil, client) + cfg := &config.Config{} + cfg.Gateway.Grok.PasswordAuthEnabled = true + svc := NewGrokOAuthService(nil, client, cfg) defer svc.Stop() info, err := svc.AuthorizePassword(context.Background(), " user@example.com ", " super-secret ", nil) @@ -168,6 +171,18 @@ func TestGrokOAuthServiceAuthorizePasswordUsesLoginThenSSOAuthorize(t *testing.T require.Equal(t, " super-secret ", client.loginPassword, "password bytes must be preserved for upstream login") } +func TestGrokOAuthServiceAuthorizePasswordDisabledByDefault(t *testing.T) { + client := &grokOAuthClientStub{} + svc := NewGrokOAuthService(nil, client) + defer svc.Stop() + + require.False(t, svc.GetCapabilities().PasswordAuthEnabled) + _, err := svc.AuthorizePassword(context.Background(), "user@example.com", "secret", nil) + require.Error(t, err) + require.Contains(t, err.Error(), "GROK_OAUTH_PASSWORD_AUTH_DISABLED") + require.Empty(t, client.loginEmail) +} + func makeGrokOAuthJWT(claims map[string]any) string { payload, _ := json.Marshal(claims) return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 0e9f61c95a..0e3cdfb99a 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -15,6 +15,10 @@ import ( "go.uber.org/zap" ) +func ProvideGrokOAuthService(proxyRepo ProxyRepository, oauthClient GrokOAuthClient, cfg *config.Config) *GrokOAuthService { + return NewGrokOAuthService(proxyRepo, oauthClient, cfg) +} + // BuildInfo contains build information type BuildInfo struct { Version string @@ -762,7 +766,7 @@ var ProviderSet = wire.NewSet( wire.Bind(new(AccountRuntimeBlocker), new(*OpenAIGatewayService)), NewOAuthService, ProvideOpenAIOAuthService, - NewGrokOAuthService, + ProvideGrokOAuthService, wire.Bind(new(GrokOAuthTokenService), new(*GrokOAuthService)), NewGeminiOAuthService, NewGeminiQuotaService, diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 18192733c7..c1e51a8c41 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -444,6 +444,10 @@ gateway: # Enabled by default because free detection requires an explicit subscription_tier/plan_type of "free". # Stats/query failures fail open so DB issues do not block all Grok traffic. grok: + # Experimental admin-only email/password authorization. Disabled by default. + # Requires YESCAPTCHA_CLIENT_KEY (or legacy YESCAPTCHA_API_KEY) in the server environment. + # TurnstileTaskProxyless uses solver egress independent from the optional account proxy. + password_auth_enabled: false free_quota_soft_gate_enabled: true free_quota_token_limit: 2000000 free_quota_soft_gate_percent: 95 diff --git a/frontend/src/api/admin/grok.ts b/frontend/src/api/admin/grok.ts index 0ae181365e..c1dc1cbaa2 100644 --- a/frontend/src/api/admin/grok.ts +++ b/frontend/src/api/admin/grok.ts @@ -19,6 +19,17 @@ export interface GrokAuthUrlRequest { redirect_uri?: string } +export interface GrokOAuthCapabilities { + password_auth_enabled: boolean +} + +const GROK_AUTHORIZATION_TIMEOUT_MS = 120_000 + +export async function getCapabilities(): Promise { + const { data } = await apiClient.get('/admin/grok/oauth/capabilities') + return data +} + export interface GrokExchangeCodeRequest { session_id: string state: string @@ -177,7 +188,9 @@ export async function validateSSOToken( ): Promise { const payload: Record = { sso_token: ssoToken } if (proxyId) payload.proxy_id = proxyId - const { data } = await apiClient.post('/admin/grok/oauth/sso-token', payload) + const { data } = await apiClient.post('/admin/grok/oauth/sso-token', payload, { + timeout: GROK_AUTHORIZATION_TIMEOUT_MS + }) return data } @@ -196,12 +209,15 @@ export async function authorizePassword( const password = idx >= 0 ? emailAndPassword.slice(idx + sep.length) : '' const payload: Record = { email, password } if (proxyId) payload.proxy_id = proxyId - const { data } = await apiClient.post('/admin/grok/oauth/password', payload) + const { data } = await apiClient.post('/admin/grok/oauth/password', payload, { + timeout: GROK_AUTHORIZATION_TIMEOUT_MS + }) return data } export default { generateAuthUrl, + getCapabilities, exchangeCode, refreshGrokToken, queryQuota, diff --git a/frontend/src/components/account/OAuthAuthorizationFlow.vue b/frontend/src/components/account/OAuthAuthorizationFlow.vue index 129f04bd0f..0c16efb857 100644 --- a/frontend/src/components/account/OAuthAuthorizationFlow.vue +++ b/frontend/src/components/account/OAuthAuthorizationFlow.vue @@ -59,7 +59,7 @@ t(getOAuthKey('ssoCookieAuth')) }} -