mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 12:57:57 +08:00
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:
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user