diff --git a/backend/internal/handler/admin/grok_import_probe_handler_test.go b/backend/internal/handler/admin/grok_import_probe_handler_test.go index c671d2ff1c..a8b7d91250 100644 --- a/backend/internal/handler/admin/grok_import_probe_handler_test.go +++ b/backend/internal/handler/admin/grok_import_probe_handler_test.go @@ -4,6 +4,7 @@ package admin import ( "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -62,6 +63,10 @@ func (grokImportOAuthClientStub) RefreshToken(context.Context, string, string, s return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil } +func (grokImportOAuthClientStub) LoginWithPassword(context.Context, string, string, string) (*service.GrokPasswordLoginResult, error) { + return nil, errors.New("unexpected password login") +} + func (grokImportOAuthClientStub) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) { return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil } diff --git a/backend/internal/handler/admin/grok_oauth_handler.go b/backend/internal/handler/admin/grok_oauth_handler.go index 1f679b9566..05853a9445 100644 --- a/backend/internal/handler/admin/grok_oauth_handler.go +++ b/backend/internal/handler/admin/grok_oauth_handler.go @@ -95,6 +95,17 @@ type GrokRefreshTokenRequest struct { ProxyID *int64 `json:"proxy_id"` } +type GrokSSOTokenRequest struct { + SSOToken string `json:"sso_token"` + ProxyID *int64 `json:"proxy_id"` +} + +type GrokPasswordAuthorizeRequest struct { + Email string `json:"email"` + Password string `json:"password"` + ProxyID *int64 `json:"proxy_id"` +} + func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) { var req GrokRefreshTokenRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -125,6 +136,38 @@ func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) { response.Success(c, tokenInfo) } +// ValidateSSOToken converts a Web SSO cookie into Build OAuth tokens. +// Response contains OAuth token info only — never echoes sso_token. +func (h *GrokOAuthHandler) ValidateSSOToken(c *gin.Context) { + var req GrokSSOTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + tokenInfo, err := h.grokOAuthService.ValidateSSOToken(c.Request.Context(), req.SSOToken, req.ProxyID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, tokenInfo) +} + +// AuthorizePassword exchanges email/password for Build OAuth tokens via SSO conversion. +// Response never includes password or raw sso_token. +func (h *GrokOAuthHandler) AuthorizePassword(c *gin.Context) { + var req GrokPasswordAuthorizeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + tokenInfo, err := h.grokOAuthService.AuthorizePassword(c.Request.Context(), req.Email, req.Password, req.ProxyID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, tokenInfo) +} + func (h *GrokOAuthHandler) RefreshAccountToken(c *gin.Context) { accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) 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 6214394882..e5a464b633 100644 --- a/backend/internal/handler/admin/grok_oauth_handler_test.go +++ b/backend/internal/handler/admin/grok_oauth_handler_test.go @@ -4,6 +4,7 @@ package admin import ( "context" + "errors" "io" "net/http" "net/http/httptest" @@ -189,6 +190,70 @@ func TestGrokOAuthHandlerRuntimeSanityDoesNotExposeSecrets(t *testing.T) { require.NotContains(t, rec.Body.String(), "client-secret-like-value") } +type grokOAuthHandlerClient struct{} + +func (c *grokOAuthHandlerClient) ExchangeCode(context.Context, string, string, string, string, string) (*xai.TokenResponse, error) { + return nil, errors.New("unexpected exchange") +} + +func (c *grokOAuthHandlerClient) RefreshToken(context.Context, string, string, string) (*xai.TokenResponse, error) { + return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil +} + +func (c *grokOAuthHandlerClient) LoginWithPassword(_ context.Context, email, _ string, _ string) (*service.GrokPasswordLoginResult, error) { + return &service.GrokPasswordLoginResult{ + Email: email, + SSOToken: "sso-from-password", + }, nil +} + +func (c *grokOAuthHandlerClient) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) { + return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil +} + +func TestGrokOAuthHandlerValidateSSOTokenReturnsTokenInfo(t *testing.T) { + gin.SetMode(gin.TestMode) + + oauthClient := &grokOAuthHandlerClient{} + oauthService := service.NewGrokOAuthService(nil, oauthClient) + defer oauthService.Stop() + handler := NewGrokOAuthHandler(oauthService, nil, nil, nil) + + router := gin.New() + router.POST("/api/v1/admin/grok/oauth/sso-token", handler.ValidateSSOToken) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/sso-token", strings.NewReader(`{"sso_token":"sso-token"}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), `"access_token":"access-token"`) + require.NotContains(t, rec.Body.String(), `"sso_token"`) +} + +func TestGrokOAuthHandlerAuthorizePasswordReturnsTokenInfoWithoutPassword(t *testing.T) { + gin.SetMode(gin.TestMode) + + oauthClient := &grokOAuthHandlerClient{} + oauthService := service.NewGrokOAuthService(nil, oauthClient) + defer oauthService.Stop() + handler := NewGrokOAuthHandler(oauthService, nil, nil, nil) + + router := gin.New() + router.POST("/api/v1/admin/grok/oauth/password", handler.AuthorizePassword) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/password", strings.NewReader(`{"email":"user@example.com","password":"super-secret"}`)) + req.Header.Set("Content-Type", "application/json") + 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 TestGrokSSOImportExpiryUsesTokenExpiryWithoutRefreshToken(t *testing.T) { tokenExpiry := time.Now().Add(6 * time.Hour).Unix() expiresAt, autoPause := grokSSOImportExpiry(nil, nil, &service.GrokTokenInfo{ diff --git a/backend/internal/repository/grok_oauth_client.go b/backend/internal/repository/grok_oauth_client.go index 38f6cfb96e..8911722429 100644 --- a/backend/internal/repository/grok_oauth_client.go +++ b/backend/internal/repository/grok_oauth_client.go @@ -1,10 +1,15 @@ package repository import ( + "bytes" "context" + "encoding/json" "errors" + "fmt" + "io" "net/http" "net/url" + "os" "strings" "time" @@ -20,6 +25,15 @@ type grokOAuthClient struct { tokenURL string } +const ( + accountsBaseURL = "https://accounts.x.ai" + loginRPCEndpoint = accountsBaseURL + "/api/rpc" + turnstileWebsiteURL = accountsBaseURL + turnstileWebsiteKey = "0x4AAAAAAAhr9JGVDZbrZOo0" + yesCaptchaCreateTask = "https://api.yescaptcha.com/createTask" + yesCaptchaGetResult = "https://api.yescaptcha.com/getTaskResult" +) + func NewGrokOAuthClient() service.GrokOAuthClient { return &grokOAuthClient{tokenURL: xai.EffectiveTokenURL()} } @@ -90,6 +104,31 @@ func (c *grokOAuthClient) RefreshToken(ctx context.Context, refreshToken, proxyU return &tokenResp, nil } +// LoginWithPassword authenticates against accounts.x.ai and returns an ephemeral SSO cookie. +// Password and SSO must never be written to account credentials or logs. +func (c *grokOAuthClient) LoginWithPassword(ctx context.Context, email, password, proxyURL string) (*service.GrokPasswordLoginResult, error) { + turnstileToken, err := solveTurnstile(ctx) + if err != nil { + return nil, err + } + httpClient, err := createGrokHTTPClient(proxyURL, true) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CLIENT_INIT_FAILED", "create HTTP client: %v", err) + } + cookieSetterURL, err := createGrokPasswordSession(ctx, httpClient, strings.TrimSpace(email), password, turnstileToken) + if err != nil { + return nil, err + } + ssoToken, err := extractGrokSSOToken(ctx, httpClient, cookieSetterURL) + if err != nil { + return nil, err + } + return &service.GrokPasswordLoginResult{ + Email: strings.TrimSpace(email), + SSOToken: ssoToken, + }, nil +} + func (c *grokOAuthClient) ConvertSSOToBuild(ctx context.Context, ssoToken, proxyURL string) (*xai.TokenResponse, error) { client, err := createGrokSSOHTTPClient(proxyURL) if err != nil { @@ -179,3 +218,199 @@ func grokOAuthHasExplicitEntitlementDenial(body string) bool { strings.Contains(lower, "subscription required") || strings.Contains(lower, "no active grok subscription") } + +func createGrokHTTPClient(proxyURL string, noRedirect bool) (*http.Client, error) { + transport := &http.Transport{} + if strings.TrimSpace(proxyURL) != "" { + parsed, err := url.Parse(proxyURL) + if err != nil { + return nil, err + } + transport.Proxy = http.ProxyURL(parsed) + } + client := &http.Client{Timeout: 120 * time.Second, Transport: transport} + if noRedirect { + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + } + return client, nil +} + +func solveTurnstile(ctx context.Context) (string, error) { + clientKey := strings.TrimSpace(os.Getenv("YESCAPTCHA_CLIENT_KEY")) + if clientKey == "" { + clientKey = strings.TrimSpace(os.Getenv("YESCAPTCHA_API_KEY")) + } + if clientKey == "" { + return "", infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_CAPTCHA_KEY_REQUIRED", "yescaptcha client key is required for Grok password authorization") + } + createBody, err := json.Marshal(map[string]any{ + "clientKey": clientKey, + "task": map[string]any{ + "type": "TurnstileTaskProxyless", + "websiteURL": turnstileWebsiteURL, + "websiteKey": turnstileWebsiteKey, + }, + }) + if err != nil { + return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_CAPTCHA_FAILED", "encode captcha create request failed: %v", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, yesCaptchaCreateTask, bytes.NewReader(createBody)) + if err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "build captcha create request failed: %v", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "create captcha task failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + var createResp struct { + ErrorID int `json:"errorId"` + TaskID string `json:"taskId"` + ErrorDescription string `json:"errorDescription"` + } + if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "decode captcha create response failed: %v", err) + } + if createResp.ErrorID != 0 || strings.TrimSpace(createResp.TaskID) == "" { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "captcha create failed: %s", createResp.ErrorDescription) + } + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(5 * time.Second): + } + body, err := json.Marshal(map[string]any{"clientKey": clientKey, "taskId": createResp.TaskID}) + if err != nil { + return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_CAPTCHA_FAILED", "encode captcha poll request failed: %v", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, yesCaptchaGetResult, bytes.NewReader(body)) + if err != nil { + continue + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + continue + } + var pollResp struct { + ErrorID int `json:"errorId"` + Status string `json:"status"` + ErrorDescription string `json:"errorDescription"` + Solution struct { + Token string `json:"token"` + } `json:"solution"` + } + err = json.NewDecoder(resp.Body).Decode(&pollResp) + _ = resp.Body.Close() + if err != nil { + continue + } + if pollResp.ErrorID != 0 { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "captcha poll failed: %s", pollResp.ErrorDescription) + } + if pollResp.Status == "ready" && strings.TrimSpace(pollResp.Solution.Token) != "" { + return pollResp.Solution.Token, nil + } + } + return "", infraerrors.New(http.StatusGatewayTimeout, "GROK_OAUTH_CAPTCHA_TIMEOUT", "captcha solve timed out") +} + +func createGrokPasswordSession(ctx context.Context, client *http.Client, email, password, turnstileToken string) (string, error) { + payload, err := json.Marshal(map[string]any{ + "rpc": "createSession", + "req": map[string]any{ + "createSessionRequest": map[string]any{ + "credentials": map[string]any{ + "case": "emailAndPassword", + "value": map[string]any{ + "email": email, + "clearTextPassword": password, + }, + }, + }, + "turnstileToken": turnstileToken, + }, + }) + if err != nil { + return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "encode password login request failed: %v", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, loginRPCEndpoint, bytes.NewReader(payload)) + if err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "build password login request failed: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", accountsBaseURL) + req.Header.Set("Referer", accountsBaseURL+"/sign-in?redirect=grok-com&email=true") + req.Header.Set("User-Agent", "Mozilla/5.0") + req.Header.Set("Accept", "*/*") + resp, err := client.Do(req) + if err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login returned status %d: %s", resp.StatusCode, logredact.RedactText(string(body))) + } + var loginResp struct { + CookieSetterURL string `json:"cookieSetterUrl"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &loginResp); err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "decode password login response failed: %v", err) + } + if strings.TrimSpace(loginResp.Error) != "" { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login error: %s", logredact.RedactText(loginResp.Error)) + } + if strings.TrimSpace(loginResp.CookieSetterURL) == "" { + return "", infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login did not return cookieSetterUrl") + } + return loginResp.CookieSetterURL, nil +} + +func extractGrokSSOToken(ctx context.Context, client *http.Client, cookieSetterURL string) (string, error) { + safeURL, err := validateGrokCookieSetterURL(cookieSetterURL) + if err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "invalid cookie setter url: %v", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, safeURL.String(), nil) + if err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "build cookie setter request: %v", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Referer", accountsBaseURL+"/") + resp, err := client.Do(req) + if err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "follow cookie setter url failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + for _, cookie := range resp.Header.Values("Set-Cookie") { + if token, ok := strings.CutPrefix(cookie, "sso="); ok { + if idx := strings.Index(token, ";"); idx > 0 { + token = token[:idx] + } + return strings.TrimSpace(token), nil + } + } + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "no sso cookie found in response (status=%d)", resp.StatusCode) +} + +func validateGrokCookieSetterURL(rawURL string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return nil, err + } + if parsed.Scheme != "https" || !strings.EqualFold(parsed.Hostname(), "accounts.x.ai") { + return nil, fmt.Errorf("url must use https://accounts.x.ai") + } + if parsed.User != nil || parsed.Port() != "" || parsed.Fragment != "" || parsed.Opaque != "" { + return nil, fmt.Errorf("url contains disallowed authority or fragment components") + } + return parsed, nil +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 161571cd53..ca02023ed5 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -466,6 +466,8 @@ func registerGrokOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { 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) + grok.POST("/oauth/sso-token", h.Admin.GrokOAuth.ValidateSSOToken) + grok.POST("/oauth/password", h.Admin.GrokOAuth.AuthorizePassword) grok.POST("/oauth/create-from-oauth", h.Admin.GrokOAuth.CreateAccountFromOAuth) grok.POST("/sso-to-oauth", h.Admin.GrokOAuth.CreateAccountsFromSSO) grok.POST("/oauth/reconcile", h.Admin.GrokOAuth.ReconcileOAuthAccounts) diff --git a/backend/internal/service/grok_oauth_service.go b/backend/internal/service/grok_oauth_service.go index 92f3937967..ea88a474b5 100644 --- a/backend/internal/service/grok_oauth_service.go +++ b/backend/internal/service/grok_oauth_service.go @@ -106,6 +106,13 @@ type GrokTokenInfo struct { EntitlementStatus string `json:"entitlement_status,omitempty"` } +// GrokPasswordLoginResult is an ephemeral password-login outcome. +// SSOToken is never persisted and must only feed ConvertSSOToBuild. +type GrokPasswordLoginResult struct { + Email string `json:"email,omitempty"` + SSOToken string `json:"sso_token"` +} + func (s *GrokOAuthService) ExchangeCode(ctx context.Context, input *GrokExchangeCodeInput) (*GrokTokenInfo, error) { if input == nil { return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_INVALID_INPUT", "input is required") @@ -176,7 +183,13 @@ func (s *GrokOAuthService) ValidateRefreshToken(ctx context.Context, refreshToke return s.RefreshToken(ctx, refreshToken, proxyURL, xai.EffectiveClientID()) } -func (s *GrokOAuthService) ConvertFromSSO(ctx context.Context, ssoToken string, proxyID *int64) (*GrokTokenInfo, error) { +// ValidateSSOToken converts a Web SSO cookie into Build OAuth tokens. +// The raw sso_token is never stored on GrokTokenInfo or account credentials. +func (s *GrokOAuthService) ValidateSSOToken(ctx context.Context, ssoToken string, proxyID *int64) (*GrokTokenInfo, error) { + ssoToken = strings.TrimSpace(ssoToken) + if ssoToken == "" { + return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_NO_SSO_TOKEN", "sso_token is required") + } proxyURL, err := s.proxyURL(ctx, proxyID) if err != nil { return nil, err @@ -185,9 +198,55 @@ func (s *GrokOAuthService) ConvertFromSSO(ctx context.Context, ssoToken string, if err != nil { return nil, err } + if err := validateGrokTokenResponse(tokenResp); err != nil { + return nil, err + } return s.tokenInfoFromResponse(tokenResp, xai.DefaultClientID, nil), nil } +// ConvertFromSSO is the batch-import entry point; same semantics as ValidateSSOToken. +func (s *GrokOAuthService) ConvertFromSSO(ctx context.Context, ssoToken string, proxyID *int64) (*GrokTokenInfo, error) { + return s.ValidateSSOToken(ctx, ssoToken, proxyID) +} + +// 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) { + email = strings.TrimSpace(email) + if email == "" { + return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_EMAIL_REQUIRED", "email is required") + } + if strings.TrimSpace(password) == "" { + return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_PASSWORD_REQUIRED", "password is required") + } + proxyURL, err := s.proxyURL(ctx, proxyID) + if err != nil { + return nil, err + } + loginResult, err := s.oauthClient.LoginWithPassword(ctx, email, password, proxyURL) + if err != nil { + return nil, err + } + if loginResult == nil || strings.TrimSpace(loginResult.SSOToken) == "" { + return nil, infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "grok password login did not return sso_token") + } + info, err := s.ValidateSSOToken(ctx, loginResult.SSOToken, proxyID) + if err != nil { + return nil, err + } + if strings.TrimSpace(info.Email) == "" { + info.Email = loginResult.Email + } + return info, nil +} + +func validateGrokTokenResponse(tokenResp *xai.TokenResponse) error { + if tokenResp == nil || strings.TrimSpace(tokenResp.AccessToken) == "" { + return infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_INVALID_TOKEN_RESPONSE", "grok oauth token response missing access_token") + } + return nil +} + func (s *GrokOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*GrokTokenInfo, error) { if account == nil || account.Platform != PlatformGrok { return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_INVALID_ACCOUNT", "account is not a Grok account") diff --git a/backend/internal/service/grok_oauth_service_test.go b/backend/internal/service/grok_oauth_service_test.go index 54baef03a2..79c2790887 100644 --- a/backend/internal/service/grok_oauth_service_test.go +++ b/backend/internal/service/grok_oauth_service_test.go @@ -16,6 +16,9 @@ import ( type grokOAuthClientStub struct { refreshResponse *xai.TokenResponse ssoResponse *xai.TokenResponse + loginResult *GrokPasswordLoginResult + loginEmail string + loginPassword string exchangeCalls int } @@ -28,6 +31,12 @@ func (s *grokOAuthClientStub) RefreshToken(context.Context, string, string, stri return s.refreshResponse, nil } +func (s *grokOAuthClientStub) LoginWithPassword(_ context.Context, email, password, _ string) (*GrokPasswordLoginResult, error) { + s.loginEmail = email + s.loginPassword = password + return s.loginResult, nil +} + func (s *grokOAuthClientStub) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) { return s.ssoResponse, nil } @@ -108,6 +117,55 @@ func TestGrokOAuthServiceConvertFromSSOExtractsBuildClaims(t *testing.T) { require.Equal(t, "user@example.com", credentials["email"]) require.Equal(t, "user-sub", credentials["sub"]) require.Equal(t, "team-1", credentials["team_id"]) + require.NotContains(t, credentials, "sso_token") +} + +func TestGrokOAuthServiceValidateSSOTokenReturnsOAuthTokensWithoutPersistingSSO(t *testing.T) { + svc := NewGrokOAuthService(nil, &grokOAuthClientStub{ + ssoResponse: &xai.TokenResponse{ + AccessToken: "access-from-sso", + RefreshToken: "refresh-from-sso", + TokenType: "Bearer", + ExpiresIn: 3600, + }, + }) + defer svc.Stop() + + info, err := svc.ValidateSSOToken(context.Background(), "sso-token", nil) + require.NoError(t, err) + require.Equal(t, "access-from-sso", info.AccessToken) + require.Equal(t, "refresh-from-sso", info.RefreshToken) + + creds := svc.BuildAccountCredentials(info) + require.NotContains(t, creds, "sso_token") + require.NotContains(t, creds, "password") +} + +func TestGrokOAuthServiceAuthorizePasswordUsesLoginThenSSOAuthorize(t *testing.T) { + client := &grokOAuthClientStub{ + loginResult: &GrokPasswordLoginResult{ + Email: "user@example.com", + SSOToken: "password-derived-sso", + }, + ssoResponse: &xai.TokenResponse{ + AccessToken: "access-from-password", + RefreshToken: "refresh-from-password", + ExpiresIn: 3600, + }, + } + svc := NewGrokOAuthService(nil, client) + defer svc.Stop() + + 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") } func makeGrokOAuthJWT(claims map[string]any) string { diff --git a/backend/internal/service/oauth_service.go b/backend/internal/service/oauth_service.go index 0b3888a73f..b92d5cc009 100644 --- a/backend/internal/service/oauth_service.go +++ b/backend/internal/service/oauth_service.go @@ -22,6 +22,9 @@ type OpenAIOAuthClient interface { type GrokOAuthClient interface { ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*xai.TokenResponse, error) RefreshToken(ctx context.Context, refreshToken, proxyURL, clientID string) (*xai.TokenResponse, error) + // LoginWithPassword exchanges email/password for a short-lived Web SSO cookie. + // Callers must convert via ConvertSSOToBuild and must not persist password or raw SSO. + LoginWithPassword(ctx context.Context, email, password, proxyURL string) (*GrokPasswordLoginResult, error) ConvertSSOToBuild(ctx context.Context, ssoToken, proxyURL string) (*xai.TokenResponse, error) }