From 6d632eec45aa794558bca4c7384c39a2cf0e8576 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Sat, 8 Aug 2026 01:07:19 +0800 Subject: [PATCH] fix(grok): tighten OAuth SSO flow and hide password login Require oauth state/redirect consistency, fail closed on missing proxy, and remove password login from create/reauth UI (admin-only password path stays off by default). --- backend/internal/config/config.go | 4 +- .../handler/admin/grok_oauth_handler.go | 10 ++- .../handler/admin/grok_oauth_handler_test.go | 3 - backend/internal/pkg/xai/oauth.go | 23 +++--- backend/internal/pkg/xai/oauth_test.go | 10 ++- .../internal/repository/grok_oauth_client.go | 14 ++++ .../repository/grok_oauth_client_test.go | 2 + .../internal/service/grok_oauth_service.go | 23 +++--- .../service/grok_oauth_service_test.go | 79 ++++++++++++++++--- .../components/account/CreateAccountModal.vue | 4 +- .../__tests__/CreateAccountModal.grok.spec.ts | 16 ++-- .../admin/account/ReAuthAccountModal.vue | 63 ++------------- .../__tests__/ReAuthAccountModal.grok.spec.ts | 23 +++--- 13 files changed, 153 insertions(+), 121 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2531b67a1c..13da0ddac1 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1038,8 +1038,8 @@ 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 controls the optional password-to-SSO OAuth flow. + // It defaults to false and must be explicitly enabled by the operator. PasswordAuthEnabled bool `mapstructure:"password_auth_enabled"` // FreeQuotaSoftGateEnabled enables a local rolling-window scheduling guard // for explicitly free Grok OAuth accounts only. diff --git a/backend/internal/handler/admin/grok_oauth_handler.go b/backend/internal/handler/admin/grok_oauth_handler.go index 8ed1094029..696a24b419 100644 --- a/backend/internal/handler/admin/grok_oauth_handler.go +++ b/backend/internal/handler/admin/grok_oauth_handler.go @@ -128,9 +128,15 @@ func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) { var proxyURL string if req.ProxyID != nil { proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID) - if err == nil && proxy != nil { - proxyURL = proxy.URL() + if err != nil { + response.ErrorFrom(c, err) + return } + if proxy == nil { + response.BadRequest(c, "GROK_OAUTH_PROXY_NOT_FOUND: proxy not found") + return + } + proxyURL = proxy.URL() } tokenInfo, err := h.grokOAuthService.RefreshToken(c.Request.Context(), refreshToken, proxyURL, req.ClientID) if 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 c809bf122f..873773220f 100644 --- a/backend/internal/handler/admin/grok_oauth_handler_test.go +++ b/backend/internal/handler/admin/grok_oauth_handler_test.go @@ -263,11 +263,8 @@ func TestGrokOAuthHandlerAuthorizePasswordReturnsTokenInfoWithoutPassword(t *tes router.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) - require.Contains(t, rec.Body.String(), `"email":"user@example.com"`) require.Contains(t, rec.Body.String(), `"access_token":"access-token"`) - require.NotContains(t, rec.Body.String(), `"sso_token"`) require.NotContains(t, rec.Body.String(), "super-secret") - require.NotContains(t, rec.Body.String(), "sso-from-password") } func TestGrokOAuthHandlerPasswordCapabilityDefaultsToDisabled(t *testing.T) { diff --git a/backend/internal/pkg/xai/oauth.go b/backend/internal/pkg/xai/oauth.go index 578c7278cd..a59d65ddf6 100644 --- a/backend/internal/pkg/xai/oauth.go +++ b/backend/internal/pkg/xai/oauth.go @@ -22,16 +22,19 @@ import ( ) const ( - OAuthIssuer = "https://auth.x.ai" - DiscoveryURL = OAuthIssuer + "/.well-known/openid-configuration" - DefaultAuthorizeURL = OAuthIssuer + "/oauth2/authorize" - DefaultTokenURL = OAuthIssuer + "/oauth2/token" - DefaultBaseURL = "https://api.x.ai/v1" - DefaultCLIBaseURL = "https://cli-chat-proxy.grok.com/v1" - DefaultClientID = "b1a00492-073a-47ea-816f-4c329264a828" - DefaultScope = "openid profile email offline_access grok-cli:access api:access" - DefaultRedirectURI = "http://127.0.0.1:56121/callback" - SessionTTL = 30 * time.Minute + OAuthIssuer = "https://auth.x.ai" + DiscoveryURL = OAuthIssuer + "/.well-known/openid-configuration" + DefaultAuthorizeURL = OAuthIssuer + "/oauth2/authorize" + DefaultTokenURL = OAuthIssuer + "/oauth2/token" + DefaultBaseURL = "https://api.x.ai/v1" + DefaultCLIBaseURL = "https://cli-chat-proxy.grok.com/v1" + DefaultUSEast1BaseURL = "https://us-east-1.api.x.ai/v1" + DefaultUSWest2BaseURL = "https://us-west-2.api.x.ai/v1" + DefaultEUWest1BaseURL = "https://eu-west-1.api.x.ai/v1" + DefaultClientID = "b1a00492-073a-47ea-816f-4c329264a828" + DefaultScope = "openid profile email offline_access grok-cli:access api:access" + DefaultRedirectURI = "http://127.0.0.1:56121/callback" + SessionTTL = 30 * time.Minute EnvAuthorizeURL = "XAI_OAUTH_AUTHORIZE_URL" EnvTokenURL = "XAI_OAUTH_TOKEN_URL" diff --git a/backend/internal/pkg/xai/oauth_test.go b/backend/internal/pkg/xai/oauth_test.go index b97e40966e..3919f4576b 100644 --- a/backend/internal/pkg/xai/oauth_test.go +++ b/backend/internal/pkg/xai/oauth_test.go @@ -255,6 +255,14 @@ func TestBuildResponsesURLWithValidatorUsesCallerPolicy(t *testing.T) { require.Equal(t, "http://grok.example.test/v1/responses", target) } +func TestValidateTrustedBaseURLAcceptsOfficialRegionalHosts(t *testing.T) { + for _, raw := range []string{DefaultUSEast1BaseURL, DefaultUSWest2BaseURL, DefaultEUWest1BaseURL} { + got, err := ValidateTrustedBaseURL(raw) + require.NoError(t, err, raw) + require.Equal(t, raw, got) + } +} + func TestBuildResponsesURLPreservesUnsafeOverrideCustomPath(t *testing.T) { t.Setenv(EnvAllowUnsafeURLOverrides, "true") @@ -356,7 +364,7 @@ func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) { require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine"]) require.Equal(t, DefaultImagineImageFastModel, mapping["grok-imagine-image"]) require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-image-quality"]) - require.Equal(t, "grok-imagine-edit", mapping["grok-imagine-edit"]) + require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-edit"]) require.Equal(t, DefaultImagineVideoModel, mapping["grok-imagine-video"]) require.Equal(t, DefaultImagineVideo15LegacyModel, mapping["grok-imagine-video-1.5"]) require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"]) diff --git a/backend/internal/repository/grok_oauth_client.go b/backend/internal/repository/grok_oauth_client.go index e78f26a4f2..c461122e7b 100644 --- a/backend/internal/repository/grok_oauth_client.go +++ b/backend/internal/repository/grok_oauth_client.go @@ -213,6 +213,20 @@ func grokOAuthStatusError(code, message string, resp *req.Response) error { 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"} { diff --git a/backend/internal/repository/grok_oauth_client_test.go b/backend/internal/repository/grok_oauth_client_test.go index 44c934c220..482c3065b0 100644 --- a/backend/internal/repository/grok_oauth_client_test.go +++ b/backend/internal/repository/grok_oauth_client_test.go @@ -122,6 +122,8 @@ func TestGrokOAuthEntitlementDenialRequiresExplicitEvidence(t *testing.T) { require.True(t, grokOAuthHasExplicitEntitlementDenial(`{"message":"no active Grok subscription"}`)) require.False(t, grokOAuthHasExplicitEntitlementDenial(`{"error":"forbidden","message":"request forbidden"}`)) require.False(t, grokOAuthHasExplicitEntitlementDenial(`403 Forbidden`)) + require.False(t, grokOAuthHasExplicitEntitlementDenial(`{"error":"access_denied","message":"You have run out of credits"}`)) + require.False(t, grokOAuthHasExplicitEntitlementDenial(`{"code":"subscription_required","message":"included free usage exhausted"}`)) } func TestNewGrokOAuthClient_UnvalidatedTokenURLFallsBackToDefault(t *testing.T) { diff --git a/backend/internal/service/grok_oauth_service.go b/backend/internal/service/grok_oauth_service.go index 24f1799093..19120e84bd 100644 --- a/backend/internal/service/grok_oauth_service.go +++ b/backend/internal/service/grok_oauth_service.go @@ -162,12 +162,16 @@ func (s *GrokOAuthService) ExchangeCode(ctx context.Context, input *GrokExchange if state == "" { state = strings.TrimSpace(parsed.State) } - if parsed.RequiresState && state == "" { - return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_STATE_REQUIRED", "oauth state is required for callback URLs") + if state == "" { + return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_STATE_REQUIRED", "oauth state is required") } - if state != "" && subtle.ConstantTimeCompare([]byte(state), []byte(session.State)) != 1 { + if subtle.ConstantTimeCompare([]byte(state), []byte(session.State)) != 1 { return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_INVALID_STATE", "invalid oauth state") } + if redirectURI := strings.TrimSpace(input.RedirectURI); redirectURI != "" && + redirectURI != strings.TrimSpace(session.RedirectURI) { + return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_REDIRECT_URI_MISMATCH", "redirect_uri does not match the OAuth session") + } proxyURL := session.ProxyURL if input.ProxyID != nil { @@ -184,15 +188,13 @@ func (s *GrokOAuthService) ExchangeCode(ctx context.Context, input *GrokExchange return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_SESSION_ALREADY_USED", "oauth session has already been used") } defer s.sessionStore.Delete(input.SessionID) - redirectURI := session.RedirectURI - if strings.TrimSpace(input.RedirectURI) != "" { - redirectURI = input.RedirectURI - } - - tokenResp, err := s.oauthClient.ExchangeCode(ctx, code, session.CodeVerifier, redirectURI, proxyURL, session.ClientID) + tokenResp, err := s.oauthClient.ExchangeCode(ctx, code, session.CodeVerifier, session.RedirectURI, proxyURL, session.ClientID) if err != nil { return nil, err } + if err := validateGrokTokenResponse(tokenResp); err != nil { + return nil, err + } return s.tokenInfoFromResponse(tokenResp, session.ClientID, nil), nil } @@ -215,6 +217,9 @@ func (s *GrokOAuthService) RefreshToken(ctx context.Context, refreshToken, proxy if err != nil { return nil, err } + if err := validateGrokTokenResponse(tokenResp); err != nil { + return nil, err + } tokenInfo := s.tokenInfoFromResponse(tokenResp, clientID, nil) if tokenInfo.RefreshToken == "" { tokenInfo.RefreshToken = refreshToken diff --git a/backend/internal/service/grok_oauth_service_test.go b/backend/internal/service/grok_oauth_service_test.go index 4437cc00ee..cbbd802e01 100644 --- a/backend/internal/service/grok_oauth_service_test.go +++ b/backend/internal/service/grok_oauth_service_test.go @@ -15,17 +15,19 @@ import ( ) type grokOAuthClientStub struct { - refreshResponse *xai.TokenResponse - ssoResponse *xai.TokenResponse - loginResult *GrokPasswordLoginResult - loginEmail string - loginPassword string - exchangeCalls int + refreshResponse *xai.TokenResponse + ssoResponse *xai.TokenResponse + loginResult *GrokPasswordLoginResult + loginEmail string + loginPassword string + exchangeCalls int + exchangeRedirectURI string } -func (s *grokOAuthClientStub) ExchangeCode(context.Context, string, string, string, string, string) (*xai.TokenResponse, error) { +func (s *grokOAuthClientStub) ExchangeCode(_ context.Context, _, _, redirectURI, _, _ string) (*xai.TokenResponse, error) { s.exchangeCalls++ - return &xai.TokenResponse{}, nil + s.exchangeRedirectURI = redirectURI + return &xai.TokenResponse{AccessToken: "access-token"}, nil } func (s *grokOAuthClientStub) RefreshToken(context.Context, string, string, string) (*xai.TokenResponse, error) { @@ -59,6 +61,18 @@ func TestGrokOAuthServiceRefreshTokenPreservesOriginalRefreshTokenWhenNotRotated require.Equal(t, "client-id", info.ClientID) } +func TestGrokOAuthServiceRefreshTokenRejectsEmptyUpstreamResponse(t *testing.T) { + svc := NewGrokOAuthService(nil, &grokOAuthClientStub{}) + defer svc.Stop() + + require.NotPanics(t, func() { + info, err := svc.RefreshToken(context.Background(), "refresh-token", "", "client-id") + require.Nil(t, info) + require.Error(t, err) + require.Contains(t, err.Error(), "GROK_OAUTH_INVALID_TOKEN_RESPONSE") + }) +} + func TestGrokOAuthServiceExchangeCodeConsumesOnlyAfterValidation(t *testing.T) { client := &grokOAuthClientStub{} svc := NewGrokOAuthService(nil, client) @@ -110,6 +124,51 @@ func TestGrokOAuthServiceExchangeCodeRejectsMissingClientWithoutConsumingSession require.True(t, ok) } +func TestGrokOAuthServiceExchangeCodeRequiresStateForBareCode(t *testing.T) { + client := &grokOAuthClientStub{} + svc := NewGrokOAuthService(nil, client) + defer svc.Stop() + auth, err := svc.GenerateAuthURL(context.Background(), nil, "") + require.NoError(t, err) + + _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{ + SessionID: auth.SessionID, + Code: "bare-authorization-code", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "GROK_OAUTH_STATE_REQUIRED") + require.Zero(t, client.exchangeCalls) + _, ok := svc.sessionStore.Get(auth.SessionID) + require.True(t, ok) +} + +func TestGrokOAuthServiceExchangeCodeRejectsRedirectURIOverride(t *testing.T) { + client := &grokOAuthClientStub{} + svc := NewGrokOAuthService(nil, client) + defer svc.Stop() + auth, err := svc.GenerateAuthURL(context.Background(), nil, "") + require.NoError(t, err) + + _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{ + SessionID: auth.SessionID, + Code: "authorization-code", + State: auth.State, + RedirectURI: "http://127.0.0.1:9999/callback", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "GROK_OAUTH_REDIRECT_URI_MISMATCH") + require.Zero(t, client.exchangeCalls) + + _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{ + SessionID: auth.SessionID, + Code: "authorization-code", + State: auth.State, + RedirectURI: xai.DefaultRedirectURI, + }) + require.NoError(t, err) + require.Equal(t, xai.DefaultRedirectURI, client.exchangeRedirectURI) +} + func TestGrokOAuthServiceExternalFlowsRejectMissingClient(t *testing.T) { svc := NewGrokOAuthService(nil, nil) defer svc.Stop() @@ -197,16 +256,16 @@ func TestGrokOAuthServiceAuthorizePasswordUsesLoginThenSSOAuthorize(t *testing.T svc := NewGrokOAuthService(nil, client, cfg) defer svc.Stop() + require.True(t, svc.GetCapabilities().PasswordAuthEnabled) info, err := svc.AuthorizePassword(context.Background(), " user@example.com ", " super-secret ", nil) require.NoError(t, err) require.Equal(t, "user@example.com", info.Email) require.Equal(t, "access-from-password", info.AccessToken) - creds := svc.BuildAccountCredentials(info) require.NotContains(t, creds, "password") require.NotContains(t, creds, "sso_token") require.Equal(t, "user@example.com", client.loginEmail) - require.Equal(t, " super-secret ", client.loginPassword, "password bytes must be preserved for upstream login") + require.Equal(t, " super-secret ", client.loginPassword) } func TestGrokOAuthServiceAuthorizePasswordDisabledByDefault(t *testing.T) { diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 7727d08d73..37fbcf6923 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -3212,7 +3212,7 @@ :show-agent-identity-option="form.platform === 'openai'" :show-codex-pat-option="form.platform === 'openai'" :show-sso-option="form.platform === 'grok'" - :show-email-password-option="form.platform === 'grok'" + :show-email-password-option="false" :show-manual-option="true" :initial-input-method="'manual'" :platform="form.platform" @@ -3316,8 +3316,8 @@
diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.grok.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.grok.spec.ts index 7dbe40520b..c0273e194f 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.grok.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.grok.spec.ts @@ -24,18 +24,12 @@ describe('CreateAccountModal Grok account types', () => { }) it('validates and applies upstream config on Grok OAuth create paths', () => { - // 授权码兑换 / RT 批量 / SSO 批量 / 密码登录(4 条路径) - expect(source.match(/validateGrokOAuthUpstreamConfig\(\)/g)?.length).toBeGreaterThanOrEqual(4) - expect(source.match(/applyGrokOAuthUpstreamConfig\(credentials\)/g)?.length).toBeGreaterThanOrEqual(4) + // 授权码兑换 / RT 批量 / SSO 批量(密码授权已隐藏) + expect(source.match(/validateGrokOAuthUpstreamConfig\(\)/g)?.length).toBeGreaterThanOrEqual(3) + expect(source.match(/applyGrokOAuthUpstreamConfig\(credentials\)/g)?.length).toBeGreaterThanOrEqual(3) }) - it('wires Grok password authorize path without storing password/SSO fields', () => { - expect(source).toContain('show-email-password-option') - expect(source).toContain('@authorize-password="handleGrokAuthorizePassword"') - expect(source).toContain('handleGrokAuthorizePassword') - expect(source).toContain('grokOAuth.authorizePassword') - expect(source).toContain('grokOAuth.buildCredentials') - // Password only for authorize call; credentials come from buildCredentials - expect(source).toContain('email----password') + it('hides Grok password authorize option in the create flow', () => { + expect(source).toContain(':show-email-password-option="false"') }) }) diff --git a/frontend/src/components/admin/account/ReAuthAccountModal.vue b/frontend/src/components/admin/account/ReAuthAccountModal.vue index 28bbf2a6cc..f13876fe60 100644 --- a/frontend/src/components/admin/account/ReAuthAccountModal.vue +++ b/frontend/src/components/admin/account/ReAuthAccountModal.vue @@ -132,18 +132,16 @@ :show-cookie-option="isAnthropic" :show-refresh-token-option="isOpenAI || isAntigravity || isGrok" :show-sso-option="isGrok" - :show-email-password-option="isGrok" + :show-email-password-option="false" :allow-multiple="false" :method-label="t('admin.accounts.inputMethod')" :platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : isGrok ? 'grok' : 'anthropic'" :show-project-id="isGemini && geminiOAuthType === 'code_assist'" :initial-input-method="grokInitialInputMethod" - :initial-email-password="grokPrefillEmailPassword" @generate-url="handleGenerateUrl" @cookie-auth="handleCookieAuth" @validate-refresh-token="handleGrokValidateRefreshToken" @import-sso="handleGrokImportSSO" - @authorize-password="handleGrokAuthorizePassword" />
@@ -257,38 +255,19 @@ const isAnthropic = computed(() => props.account?.platform === 'anthropic') const isAntigravity = computed(() => props.account?.platform === 'antigravity') const isGrok = computed(() => props.account?.platform === 'grok') -/** Stored Grok email for reauth prefill (password is never stored). */ -const grokAccountEmail = computed(() => { - if (!isGrok.value || !props.account) return '' - const creds = (props.account.credentials || {}) as Record - const email = typeof creds.email === 'string' ? creds.email.trim() : '' - return email -}) - /** - * Prefill "email----" so the operator only types the password. - * Empty when no email is known (full email----password required). - */ -const grokPrefillEmailPassword = computed(() => { - const email = grokAccountEmail.value - return email ? `${email}----` : '' -}) - -/** - * Grok reauth default tab: - * - password first when we know the email (common reauth path) - * - refresh_token when email unknown but RT may still work - * - email_password otherwise + * Grok reauth default tab (password auth is hidden): + * - refresh_token when RT may still work + * - SSO cookie otherwise */ const grokInitialInputMethod = computed(() => { if (!isGrok.value) return 'manual' - if (grokAccountEmail.value) return 'email_password' const creds = (props.account?.credentials || {}) as Record const hasRT = (typeof creds.refresh_token === 'string' && creds.refresh_token.trim() !== '') || (typeof creds.has_refresh_token === 'boolean' && creds.has_refresh_token) if (hasRT) return 'refresh_token' - return 'email_password' + return 'sso_cookie' }) // Computed - current OAuth state based on platform @@ -673,38 +652,6 @@ const handleGrokImportSSO = async (ssoInput: string) => { } } -/** Re-auth with email----password (single line; password never persisted). */ -const handleGrokAuthorizePassword = async (emailPasswordInput: string) => { - if (!props.account || !isGrok.value) return - const line = emailPasswordInput - .split('\n') - // Email is normalized in the API helper, but password whitespace is valid. - .find((item) => item.trim() && item.includes('----')) - if (!line) { - grokOAuth.error.value = t( - 'admin.accounts.oauth.grok.pleaseEnterPassword', - 'Please enter email----password' - ) - return - } - - grokOAuth.loading.value = true - grokOAuth.error.value = '' - try { - const tokenInfo = await grokOAuth.authorizePassword(line, props.account.proxy_id) - if (!tokenInfo) return - await applyGrokReauthTokenInfo(tokenInfo) - } catch (error: any) { - grokOAuth.error.value = - error.response?.data?.detail || - error.message || - t('admin.accounts.oauth.grok.failedToAuthorizePassword', 'Password authorization failed') - appStore.showError(grokOAuth.error.value) - } finally { - grokOAuth.loading.value = false - } -} - /** Re-auth with a single refresh token. */ const handleGrokValidateRefreshToken = async (refreshTokenInput: string) => { if (!props.account || !isGrok.value) return diff --git a/frontend/src/components/admin/account/__tests__/ReAuthAccountModal.grok.spec.ts b/frontend/src/components/admin/account/__tests__/ReAuthAccountModal.grok.spec.ts index 1291354c59..87dbf5d1e4 100644 --- a/frontend/src/components/admin/account/__tests__/ReAuthAccountModal.grok.spec.ts +++ b/frontend/src/components/admin/account/__tests__/ReAuthAccountModal.grok.spec.ts @@ -8,18 +8,16 @@ const source = readFileSync( ) describe('ReAuthAccountModal Grok re-auth paths', () => { - it('exposes SSO cookie, email-password, and refresh-token options for Grok', () => { + it('exposes SSO cookie and refresh-token options; password auth stays hidden', () => { expect(source).toContain(':show-sso-option="isGrok"') - expect(source).toContain(':show-email-password-option="isGrok"') + expect(source).toContain(':show-email-password-option="false"') expect(source).toContain(':show-refresh-token-option="isOpenAI || isAntigravity || isGrok"') + expect(source).not.toContain('@authorize-password=') }) - it('wires password and SSO handlers without batch account create', () => { - expect(source).toContain('@authorize-password="handleGrokAuthorizePassword"') + it('wires SSO and RT reauth without batch account create', () => { expect(source).toContain('@import-sso="handleGrokImportSSO"') expect(source).toContain('@validate-refresh-token="handleGrokValidateRefreshToken"') - expect(source).toContain('handleGrokAuthorizePassword') - expect(source).toContain('grokOAuth.authorizePassword') expect(source).toContain('grokOAuth.validateSSOToken') expect(source).toContain('grokOAuth.buildCredentials') // Re-auth updates the existing account; must not call createFromSSO batch create @@ -27,18 +25,17 @@ describe('ReAuthAccountModal Grok re-auth paths', () => { expect(source).toContain('applyOAuthCredentials') }) - it('hides footer code-exchange button for SSO/password/RT input methods', () => { + it('hides footer code-exchange button for SSO/RT input methods', () => { expect(source).toContain("method === 'sso_cookie'") - expect(source).toContain("method === 'email_password'") expect(source).toContain("method === 'refresh_token'") }) - it('prefills email---- and defaults to password method when email is known', () => { - expect(source).toContain('grokPrefillEmailPassword') + it('defaults reauth to refresh_token or sso_cookie (not password)', () => { expect(source).toContain('grokInitialInputMethod') - expect(source).toContain(':initial-email-password="grokPrefillEmailPassword"') expect(source).toContain(':initial-input-method="grokInitialInputMethod"') - expect(source).toContain('email----') - expect(source).toContain("return 'email_password'") + expect(source).toContain("return 'sso_cookie'") + expect(source).toContain("return 'refresh_token'") + expect(source).not.toContain("return 'email_password'") + expect(source).not.toContain('grokPrefillEmailPassword') }) })