mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-06 17:16:27 +08:00
feat(grok): 补齐密码登录与 SSO 校验,统一 OAuth 凭证形态
在 main 既有 SSO→Build 批量导入之上增加 sso-token 校验与账号密码授权。 密码仅用于换取 SSO 再转 Build OAuth,明文与 raw SSO 均不落库。
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user