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).
This commit is contained in:
IanShaw027
2026-08-08 01:07:19 +08:00
parent d0767eab9d
commit 6d632eec45
13 changed files with 153 additions and 121 deletions
+2 -2
View File
@@ -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.
@@ -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 {
@@ -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) {
+13 -10
View File
@@ -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"
+9 -1
View File
@@ -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"])
@@ -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"} {
@@ -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(`<html>403 Forbidden</html>`))
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) {
+14 -9
View File
@@ -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
@@ -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) {
@@ -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 @@
<BaseDialog
:show="showGeminiHelpDialog"
:title="t('admin.accounts.gemini.helpDialog.title')"
width="wide"
@close="showGeminiHelpDialog = false"
max-width="max-w-3xl"
>
<div class="space-y-6">
<!-- Setup Guide Section -->
@@ -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"')
})
})
@@ -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"
/>
</div>
@@ -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<string, unknown>
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<AuthInputMethod>(() => {
if (!isGrok.value) return 'manual'
if (grokAccountEmail.value) return 'email_password'
const creds = (props.account?.credentials || {}) as Record<string, unknown>
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
@@ -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')
})
})