Merge pull request #4217 from jinfeijie/feat/grok-sso-device-oauth

feat(grok): 支持 Web SSO 批量导入并转换为 Build OAuth
This commit is contained in:
Wesley Liddick
2026-07-14 10:25:56 +08:00
committed by GitHub
17 changed files with 1276 additions and 30 deletions
@@ -1,16 +1,23 @@
package admin
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
const grokSSOImportConcurrency = 3
type GrokOAuthHandler struct {
grokOAuthService *service.GrokOAuthService
adminService service.AdminService
@@ -205,6 +212,238 @@ func (h *GrokOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
response.Success(c, dto.AccountFromService(account))
}
type GrokSSOToOAuthRequest struct {
SSOTokens []string `json:"sso_tokens"`
SSOToken string `json:"sso_token"`
Name string `json:"name"`
Notes *string `json:"notes"`
ProxyID *int64 `json:"proxy_id"`
GroupIDs []int64 `json:"group_ids"`
Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"`
Concurrency int `json:"concurrency"`
LoadFactor *int `json:"load_factor"`
Priority int `json:"priority"`
RateMultiplier *float64 `json:"rate_multiplier"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
}
type GrokSSOToOAuthItemResult struct {
Index int `json:"index"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Account *dto.Account `json:"account,omitempty"`
Error string `json:"error,omitempty"`
}
type GrokSSOToOAuthResponse struct {
Created []GrokSSOToOAuthItemResult `json:"created"`
Failed []GrokSSOToOAuthItemResult `json:"failed"`
}
type grokSSOImportJob struct {
index int
token string
}
type grokSSOImportWorkerResult struct {
created bool
item GrokSSOToOAuthItemResult
}
func (h *GrokOAuthHandler) CreateAccountsFromSSO(c *gin.Context) {
var req GrokSSOToOAuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
tokens := normalizeSSOImportTokens(req.SSOTokens, req.SSOToken)
if len(tokens) == 0 {
response.BadRequest(c, "sso_tokens is required")
return
}
ctx := c.Request.Context()
workerCount := grokSSOImportConcurrency
if len(tokens) < workerCount {
workerCount = len(tokens)
}
jobs := make(chan grokSSOImportJob)
items := make([]grokSSOImportWorkerResult, len(tokens))
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
items[job.index] = h.safeCreateAccountFromSSOToken(ctx, req, job.token, job.index+1, len(tokens))
}
}()
}
for i, token := range tokens {
jobs <- grokSSOImportJob{index: i, token: token}
}
close(jobs)
wg.Wait()
result := GrokSSOToOAuthResponse{
Created: make([]GrokSSOToOAuthItemResult, 0, len(tokens)),
Failed: make([]GrokSSOToOAuthItemResult, 0),
}
for _, item := range items {
if item.created {
result.Created = append(result.Created, item.item)
} else {
result.Failed = append(result.Failed, item.item)
}
}
response.Success(c, result)
}
func (h *GrokOAuthHandler) safeCreateAccountFromSSOToken(ctx context.Context, req GrokSSOToOAuthRequest, token string, index, total int) (result grokSSOImportWorkerResult) {
defer func() {
if recovered := recover(); recovered != nil {
slog.Error("grok_sso_import_worker_panic", "index", index, "recover", recovered)
result = grokSSOImportWorkerResult{
item: GrokSSOToOAuthItemResult{
Index: index,
Error: fmt.Sprintf("internal worker panic: %v", recovered),
},
}
}
}()
return h.createAccountFromSSOToken(ctx, req, token, index, total)
}
func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req GrokSSOToOAuthRequest, token string, index, total int) grokSSOImportWorkerResult {
tokenInfo, err := h.grokOAuthService.ConvertFromSSO(ctx, token, req.ProxyID)
if err != nil {
return grokSSOImportWorkerResult{item: GrokSSOToOAuthItemResult{Index: index, Error: grokSSOImportErrorMessage(err)}}
}
credentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo)
credentials = service.MergeCredentials(cloneGrokSSOMap(req.Credentials), credentials)
name := grokSSOImportAccountName(req.Name, tokenInfo, index, total)
expiresAt, autoPauseOnExpired := grokSSOImportExpiry(req.ExpiresAt, req.AutoPauseOnExpired, tokenInfo)
account, err := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
Name: name,
Notes: req.Notes,
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Credentials: credentials,
Extra: cloneGrokSSOMap(req.Extra),
ProxyID: req.ProxyID,
Concurrency: req.Concurrency,
LoadFactor: req.LoadFactor,
Priority: req.Priority,
RateMultiplier: req.RateMultiplier,
GroupIDs: append([]int64(nil), req.GroupIDs...),
ExpiresAt: expiresAt,
AutoPauseOnExpired: autoPauseOnExpired,
})
if err != nil {
return grokSSOImportWorkerResult{item: GrokSSOToOAuthItemResult{Index: index, Name: name, Email: tokenInfo.Email, Error: grokSSOImportErrorMessage(err)}}
}
return grokSSOImportWorkerResult{
created: true,
item: GrokSSOToOAuthItemResult{
Index: index,
Name: name,
Email: tokenInfo.Email,
Account: dto.AccountFromService(account),
},
}
}
func grokSSOImportExpiry(requestExpiresAt *int64, requestAutoPause *bool, tokenInfo *service.GrokTokenInfo) (*int64, *bool) {
if tokenInfo == nil || strings.TrimSpace(tokenInfo.RefreshToken) != "" || tokenInfo.ExpiresAt <= 0 {
return requestExpiresAt, requestAutoPause
}
expiresAt := tokenInfo.ExpiresAt
if requestExpiresAt != nil && *requestExpiresAt > 0 && *requestExpiresAt < expiresAt {
expiresAt = *requestExpiresAt
}
autoPause := true
return &expiresAt, &autoPause
}
func cloneGrokSSOMap(source map[string]any) map[string]any {
if source == nil {
return nil
}
clone := make(map[string]any, len(source))
for key, value := range source {
clone[key] = cloneGrokSSOValue(value)
}
return clone
}
func cloneGrokSSOValue(value any) any {
switch v := value.(type) {
case map[string]any:
return cloneGrokSSOMap(v)
case []any:
clone := make([]any, len(v))
for i, item := range v {
clone[i] = cloneGrokSSOValue(item)
}
return clone
default:
return value
}
}
func normalizeSSOImportTokens(tokens []string, single string) []string {
items := make([]string, 0, len(tokens)+1)
if strings.TrimSpace(single) != "" {
items = append(items, single)
}
items = append(items, tokens...)
seen := make(map[string]struct{}, len(items))
result := make([]string, 0, len(items))
for _, item := range items {
parts := strings.Split(strings.NewReplacer(",", "\n", "\r", "\n").Replace(item), "\n")
for _, token := range parts {
if token = xai.NormalizeSSOToken(token); token == "" {
continue
}
if _, ok := seen[token]; ok {
continue
}
seen[token] = struct{}{}
result = append(result, token)
}
}
return result
}
func grokSSOImportAccountName(base string, tokenInfo *service.GrokTokenInfo, index, total int) string {
base = strings.TrimSpace(base)
if base == "" && tokenInfo != nil {
base = strings.TrimSpace(tokenInfo.Email)
}
if base == "" {
base = "Grok OAuth Account"
}
if total > 1 {
return base + " #" + strconv.Itoa(index)
}
return base
}
func grokSSOImportErrorMessage(err error) string {
status := infraerrors.FromError(err)
if status == nil {
return ""
}
if status.Reason != "" {
return status.Reason + ": " + status.Message
}
return status.Message
}
func (h *GrokOAuthHandler) QueryQuota(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -168,3 +168,51 @@ func TestGrokOAuthHandlerRuntimeSanityDoesNotExposeSecrets(t *testing.T) {
require.NotContains(t, rec.Body.String(), "secret")
require.NotContains(t, rec.Body.String(), "client-secret-like-value")
}
func TestGrokSSOImportExpiryUsesTokenExpiryWithoutRefreshToken(t *testing.T) {
tokenExpiry := time.Now().Add(6 * time.Hour).Unix()
expiresAt, autoPause := grokSSOImportExpiry(nil, nil, &service.GrokTokenInfo{
ExpiresAt: tokenExpiry,
})
require.NotNil(t, expiresAt)
require.Equal(t, tokenExpiry, *expiresAt)
require.NotNil(t, autoPause)
require.True(t, *autoPause)
}
func TestGrokSSOImportExpiryUsesEarlierRequestedExpiryWithoutRefreshToken(t *testing.T) {
requestedExpiry := time.Now().Add(2 * time.Hour).Unix()
tokenExpiry := time.Now().Add(6 * time.Hour).Unix()
requestedAutoPause := false
expiresAt, autoPause := grokSSOImportExpiry(&requestedExpiry, &requestedAutoPause, &service.GrokTokenInfo{
ExpiresAt: tokenExpiry,
})
require.NotNil(t, expiresAt)
require.Equal(t, requestedExpiry, *expiresAt)
require.NotNil(t, autoPause)
require.True(t, *autoPause)
}
func TestGrokSSOImportExpiryPreservesRequestSettingsWithRefreshToken(t *testing.T) {
requestedExpiry := time.Now().Add(2 * time.Hour).Unix()
requestedAutoPause := false
expiresAt, autoPause := grokSSOImportExpiry(&requestedExpiry, &requestedAutoPause, &service.GrokTokenInfo{
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(6 * time.Hour).Unix(),
})
require.Same(t, &requestedExpiry, expiresAt)
require.Same(t, &requestedAutoPause, autoPause)
}
func TestGrokSSOImportWorkerRecoversPanic(t *testing.T) {
h := &GrokOAuthHandler{}
result := h.safeCreateAccountFromSSOToken(context.Background(), GrokSSOToOAuthRequest{}, "token", 2, 3)
// Without a service, createAccountFromSSOToken would panic on nil service access.
// Recovery must convert that into a failed item and keep the worker alive.
require.False(t, result.created)
require.Equal(t, 2, result.item.Index)
require.Contains(t, result.item.Error, "internal worker panic")
}
+418
View File
@@ -0,0 +1,418 @@
package xai
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
const (
SSOBuildScope = "openid profile email offline_access grok-cli:access api:access conversations:read conversations:write"
SSOAccountsURL = "https://accounts.x.ai/"
SSODeviceURL = OAuthIssuer + "/oauth2/device/code"
SSOVerifyURL = OAuthIssuer + "/oauth2/device/verify"
SSOApproveURL = OAuthIssuer + "/oauth2/device/approve"
SSOTokenURL = OAuthIssuer + "/oauth2/token"
SSOConversionTimeout = 90 * time.Second
ssoMaxAuthBody = 2 << 20
ssoDefaultUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
ssoDefaultTokenTTL = 6 * time.Hour
)
var (
ErrSSOUnauthorized = errors.New("xai sso unauthorized")
ErrSSOAuthorizationDenied = errors.New("xai device authorization denied")
)
type SSOHTTPError struct{ Status int }
func (e SSOHTTPError) Error() string { return fmt.Sprintf("xAI OAuth HTTP %d", e.Status) }
type SSODeviceHTTPClient interface {
Do(*http.Request) (*http.Response, error)
}
type SSODeviceOptions struct {
HTTPClient SSODeviceHTTPClient
UserAgent string
Sleep func(context.Context, time.Duration) error
}
type ssoDeviceFlow struct {
client SSODeviceHTTPClient
userAgent string
cookies map[string]string
sleep func(context.Context, time.Duration) error
}
func ConvertSSOToBuild(ctx context.Context, ssoToken string, opts *SSODeviceOptions) (*TokenResponse, error) {
ssoToken = NormalizeSSOToken(ssoToken)
if ssoToken == "" {
return nil, ErrSSOUnauthorized
}
if opts == nil {
opts = &SSODeviceOptions{}
}
client := opts.HTTPClient
if client == nil {
client = &http.Client{
Timeout: SSOConversionTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
userAgent := strings.TrimSpace(opts.UserAgent)
if userAgent == "" {
userAgent = ssoDefaultUA
}
sleep := opts.Sleep
if sleep == nil {
sleep = sleepContext
}
flow := &ssoDeviceFlow{
client: client,
userAgent: userAgent,
cookies: map[string]string{"sso": ssoToken, "sso-rw": ssoToken},
sleep: sleep,
}
return flow.convert(ctx)
}
func (f *ssoDeviceFlow) convert(ctx context.Context) (*TokenResponse, error) {
status, finalURL, _, err := f.do(ctx, http.MethodGet, SSOAccountsURL, nil)
if err != nil {
return nil, err
}
if status == http.StatusUnauthorized || strings.Contains(finalURL, "sign-in") || strings.Contains(finalURL, "sign-up") {
return nil, ErrSSOUnauthorized
}
if status < 200 || status >= 400 {
return nil, fmt.Errorf("validate Grok Web SSO: %w", SSOHTTPError{Status: status})
}
status, _, body, err := f.do(ctx, http.MethodPost, SSODeviceURL, url.Values{
"client_id": {DefaultClientID},
"scope": {SSOBuildScope},
})
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, fmt.Errorf("start xAI device flow: %w", SSOHTTPError{Status: status})
}
var device struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURIComplete string `json:"verification_uri_complete"`
Interval int `json:"interval"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &device); err != nil {
return nil, fmt.Errorf("parse xAI device flow response: %w", err)
}
if device.DeviceCode == "" || device.UserCode == "" || !safeXAIAuthURL(device.VerificationURIComplete) {
return nil, errors.New("xAI device flow response is incomplete")
}
if device.Interval <= 0 {
device.Interval = 5
}
if device.ExpiresIn <= 0 {
device.ExpiresIn = 1800
}
status, _, _, err = f.do(ctx, http.MethodGet, device.VerificationURIComplete, nil)
if err != nil {
return nil, err
}
if status < 200 || status >= 400 {
return nil, fmt.Errorf("open xAI device verification page: %w", SSOHTTPError{Status: status})
}
status, finalURL, _, err = f.do(ctx, http.MethodPost, SSOVerifyURL, url.Values{"user_code": {device.UserCode}})
if err != nil {
return nil, err
}
if status < 200 || status >= 400 {
return nil, fmt.Errorf("verify xAI device code: %w", SSOHTTPError{Status: status})
}
if !strings.Contains(finalURL, "consent") {
return nil, errors.New("xAI device verification did not reach consent page")
}
status, finalURL, _, err = f.do(ctx, http.MethodPost, SSOApproveURL, url.Values{
"user_code": {device.UserCode},
"action": {"allow"},
"principal_type": {"User"},
"principal_id": {""},
})
if err != nil {
return nil, err
}
if status < 200 || status >= 400 {
return nil, fmt.Errorf("approve xAI device code: %w", SSOHTTPError{Status: status})
}
if !strings.Contains(finalURL, "done") {
return nil, errors.New("xAI device approval did not reach done page")
}
return f.pollToken(ctx, device.DeviceCode, time.Duration(device.Interval)*time.Second, time.Duration(device.ExpiresIn)*time.Second)
}
func (f *ssoDeviceFlow) pollToken(ctx context.Context, deviceCode string, interval, expiresIn time.Duration) (*TokenResponse, error) {
if interval < time.Second {
interval = time.Second
}
deadline := time.Now().Add(minDuration(expiresIn, 75*time.Second))
for time.Now().Before(deadline) {
if err := f.sleep(ctx, interval); err != nil {
return nil, err
}
status, _, body, err := f.do(ctx, http.MethodPost, SSOTokenURL, url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
"client_id": {DefaultClientID},
"device_code": {deviceCode},
})
if err != nil {
return nil, err
}
var payload struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
Scope string `json:"scope"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, fmt.Errorf("parse xAI token response: %w", err)
}
if status >= 200 && status < 300 && payload.AccessToken != "" {
if payload.ExpiresIn <= 0 {
payload.ExpiresIn = int64(ssoDefaultTokenTTL.Seconds())
}
if payload.TokenType == "" {
payload.TokenType = "Bearer"
}
return &TokenResponse{
AccessToken: payload.AccessToken,
RefreshToken: payload.RefreshToken,
IDToken: payload.IDToken,
TokenType: payload.TokenType,
ExpiresIn: payload.ExpiresIn,
Scope: payload.Scope,
}, nil
}
switch payload.Error {
case "authorization_pending":
continue
case "slow_down":
interval += 5 * time.Second
continue
case "access_denied", "expired_token":
return nil, ErrSSOAuthorizationDenied
default:
if status >= 400 {
return nil, fmt.Errorf("xAI token polling failed (%s): %w", firstNonEmpty(payload.ErrorDescription, payload.Error), SSOHTTPError{Status: status})
}
return nil, fmt.Errorf("xAI token polling failed: %s", firstNonEmpty(payload.ErrorDescription, payload.Error, strconv.Itoa(status)))
}
}
return nil, errors.New("xAI device flow token polling timed out")
}
func (f *ssoDeviceFlow) do(ctx context.Context, method, endpoint string, form url.Values) (int, string, []byte, error) {
if !safeXAIAuthURL(endpoint) {
return 0, "", nil, errors.New("xAI OAuth URL is not trusted")
}
currentURL := endpoint
currentMethod := method
currentForm := form
for redirects := 0; redirects <= 8; redirects++ {
var body io.Reader
if currentForm != nil {
body = strings.NewReader(currentForm.Encode())
}
request, err := http.NewRequestWithContext(ctx, currentMethod, currentURL, body)
if err != nil {
return 0, currentURL, nil, err
}
request.Header.Set("Accept", "application/json, text/html;q=0.9, */*;q=0.8")
request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
request.Header.Set("User-Agent", f.userAgent)
if cookie := f.cookieHeader(); cookie != "" {
request.Header.Set("Cookie", cookie)
}
if currentForm != nil {
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
response, err := f.client.Do(request)
if err != nil {
return 0, currentURL, nil, err
}
f.captureCookies(response)
data, readErr := io.ReadAll(io.LimitReader(response.Body, ssoMaxAuthBody+1))
_ = response.Body.Close()
if readErr != nil {
return response.StatusCode, currentURL, nil, readErr
}
if len(data) > ssoMaxAuthBody {
return response.StatusCode, currentURL, nil, errors.New("xAI OAuth response exceeds 2 MiB")
}
if response.StatusCode < 300 || response.StatusCode > 399 {
return response.StatusCode, currentURL, data, nil
}
location := strings.TrimSpace(response.Header.Get("Location"))
if location == "" {
return response.StatusCode, currentURL, data, errors.New("xAI OAuth redirect missing Location")
}
base, _ := url.Parse(currentURL)
next, err := url.Parse(location)
if err != nil {
return response.StatusCode, currentURL, data, err
}
currentURL = base.ResolveReference(next).String()
if !safeXAIAuthURL(currentURL) {
return response.StatusCode, currentURL, data, errors.New("xAI OAuth redirected to untrusted host")
}
if response.StatusCode == http.StatusSeeOther || ((response.StatusCode == http.StatusMovedPermanently || response.StatusCode == http.StatusFound) && currentMethod != http.MethodGet && currentMethod != http.MethodHead) {
currentMethod = http.MethodGet
currentForm = nil
}
}
return 0, currentURL, nil, errors.New("xAI OAuth redirected too many times")
}
func (f *ssoDeviceFlow) captureCookies(response *http.Response) {
for _, cookie := range response.Cookies() {
name := strings.TrimSpace(cookie.Name)
value := strings.TrimSpace(cookie.Value)
if name == "" || len(name) > 128 || len(value) > 16384 || strings.ContainsAny(name+value, "\r\n\x00") {
continue
}
if cookie.MaxAge < 0 {
delete(f.cookies, name)
continue
}
f.cookies[name] = value
}
}
func (f *ssoDeviceFlow) cookieHeader() string {
keys := make([]string, 0, len(f.cookies))
for key := range f.cookies {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, key+"="+f.cookies[key])
}
return strings.Join(parts, "; ")
}
func safeXAIAuthURL(raw string) bool {
parsed, err := url.Parse(raw)
if err != nil || parsed.User != nil || parsed.Hostname() == "" {
return false
}
if AllowUnsafeURLOverrides() {
return parsed.Scheme != "" && parsed.Host != ""
}
if parsed.Scheme != "https" {
return false
}
host := strings.ToLower(parsed.Hostname())
return host == "x.ai" || strings.HasSuffix(host, ".x.ai")
}
func NormalizeSSOToken(value string) string {
value = strings.TrimSpace(value)
if strings.HasPrefix(strings.ToLower(value), "cookie:") {
value = strings.TrimSpace(value[len("cookie:"):])
}
for _, part := range strings.Split(value, ";") {
name, token, found := strings.Cut(strings.TrimSpace(part), "=")
if !found {
continue
}
switch strings.ToLower(strings.TrimSpace(name)) {
case "sso", "sso-rw":
return sanitizeSSOToken(token)
}
}
if token, _, found := strings.Cut(value, ";"); found {
value = strings.TrimSpace(token)
}
return sanitizeSSOToken(value)
}
func sanitizeSSOToken(value string) string {
return strings.NewReplacer("\r", "", "\n", "", "\x00", "").Replace(strings.TrimSpace(value))
}
func DecodeJWTClaims(token string) map[string]any {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return nil
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil
}
var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil {
return nil
}
return claims
}
func JWTClaimString(claims map[string]any, key string) string {
value, _ := claims[key].(string)
return strings.TrimSpace(value)
}
func sleepContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func minDuration(a, b time.Duration) time.Duration {
if a <= 0 {
return b
}
if a < b {
return a
}
return b
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
+115
View File
@@ -0,0 +1,115 @@
//go:build unit
package xai
import (
"context"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type ssoDeviceFakeClient struct {
t *testing.T
tokenCalls int
cookieHeaders []string
}
func (c *ssoDeviceFakeClient) Do(req *http.Request) (*http.Response, error) {
c.cookieHeaders = append(c.cookieHeaders, req.Header.Get("Cookie"))
switch req.URL.String() {
case SSOAccountsURL:
require.Equal(c.t, http.MethodGet, req.Method)
return ssoDeviceResponse(http.StatusOK, http.Header{"Set-Cookie": {"session=web-session; Path=/"}}, `{}`), nil
case SSODeviceURL:
require.Equal(c.t, http.MethodPost, req.Method)
values := readSSODeviceForm(c.t, req)
require.Equal(c.t, DefaultClientID, values.Get("client_id"))
require.Equal(c.t, SSOBuildScope, values.Get("scope"))
return ssoDeviceResponse(http.StatusOK, http.Header{"Set-Cookie": {"csrf=csrf-token; Path=/"}}, `{"device_code":"device-1","user_code":"USER-1","verification_uri_complete":"https://auth.x.ai/oauth2/device/complete","interval":1,"expires_in":60}`), nil
case "https://auth.x.ai/oauth2/device/complete":
require.Equal(c.t, http.MethodGet, req.Method)
return ssoDeviceResponse(http.StatusOK, nil, `<html>ok</html>`), nil
case SSOVerifyURL:
require.Equal(c.t, http.MethodPost, req.Method)
values := readSSODeviceForm(c.t, req)
require.Equal(c.t, "USER-1", values.Get("user_code"))
return ssoDeviceResponse(http.StatusFound, http.Header{"Location": {"/oauth2/device/consent"}}, ``), nil
case "https://auth.x.ai/oauth2/device/consent":
require.Equal(c.t, http.MethodGet, req.Method)
return ssoDeviceResponse(http.StatusOK, nil, `<html>consent</html>`), nil
case SSOApproveURL:
require.Equal(c.t, http.MethodPost, req.Method)
values := readSSODeviceForm(c.t, req)
require.Equal(c.t, "USER-1", values.Get("user_code"))
require.Equal(c.t, "allow", values.Get("action"))
require.Equal(c.t, "User", values.Get("principal_type"))
return ssoDeviceResponse(http.StatusSeeOther, http.Header{"Location": {"/oauth2/device/done"}}, ``), nil
case "https://auth.x.ai/oauth2/device/done":
require.Equal(c.t, http.MethodGet, req.Method)
return ssoDeviceResponse(http.StatusOK, nil, `<html>done</html>`), nil
case SSOTokenURL:
require.Equal(c.t, http.MethodPost, req.Method)
c.tokenCalls++
values := readSSODeviceForm(c.t, req)
require.Equal(c.t, "urn:ietf:params:oauth:grant-type:device_code", values.Get("grant_type"))
require.Equal(c.t, "device-1", values.Get("device_code"))
return ssoDeviceResponse(http.StatusOK, nil, `{"access_token":"access-token","refresh_token":"refresh-token","id_token":"id-token","token_type":"Bearer","expires_in":3600,"scope":"`+SSOBuildScope+`"}`), nil
default:
c.t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
return nil, nil
}
}
func TestConvertSSOToBuildCompletesDeviceFlow(t *testing.T) {
t.Setenv(EnvClientID, "")
client := &ssoDeviceFakeClient{t: t}
token, err := ConvertSSOToBuild(context.Background(), "sso=sso-token; ignored=1", &SSODeviceOptions{
HTTPClient: client,
Sleep: func(context.Context, time.Duration) error {
return nil
},
})
require.NoError(t, err)
require.Equal(t, "access-token", token.AccessToken)
require.Equal(t, "refresh-token", token.RefreshToken)
require.Equal(t, "id-token", token.IDToken)
require.Equal(t, SSOBuildScope, token.Scope)
require.Equal(t, 1, client.tokenCalls)
require.Contains(t, client.cookieHeaders[0], "sso=sso-token")
require.Contains(t, client.cookieHeaders[0], "sso-rw=sso-token")
require.Contains(t, client.cookieHeaders[len(client.cookieHeaders)-1], "session=web-session")
require.Contains(t, client.cookieHeaders[len(client.cookieHeaders)-1], "csrf=csrf-token")
}
func TestNormalizeSSOTokenAcceptsCookieHeader(t *testing.T) {
require.Equal(t, "token-1", NormalizeSSOToken("Cookie: foo=bar; sso=token-1; sso-rw=token-2"))
require.Equal(t, "token-2", NormalizeSSOToken("sso-rw=token-2; foo=bar"))
require.Equal(t, "raw-token", NormalizeSSOToken(" raw-token ; ignored=1"))
}
func ssoDeviceResponse(status int, header http.Header, body string) *http.Response {
if header == nil {
header = http.Header{}
}
return &http.Response{
StatusCode: status,
Header: header,
Body: io.NopCloser(strings.NewReader(body)),
}
}
func readSSODeviceForm(t *testing.T, req *http.Request) url.Values {
t.Helper()
data, err := io.ReadAll(req.Body)
require.NoError(t, err)
values, err := url.ParseQuery(string(data))
require.NoError(t, err)
return values
}
@@ -2,12 +2,14 @@ package repository
import (
"context"
"errors"
"net/http"
"net/url"
"strings"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
sharedhttp "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
@@ -88,6 +90,21 @@ func (c *grokOAuthClient) RefreshToken(ctx context.Context, refreshToken, proxyU
return &tokenResp, nil
}
func (c *grokOAuthClient) ConvertSSOToBuild(ctx context.Context, ssoToken, proxyURL string) (*xai.TokenResponse, error) {
client, err := createGrokSSOHTTPClient(proxyURL)
if err != nil {
return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_SSO_CLIENT_INIT_FAILED", "create HTTP client: %v", err)
}
requestCtx, cancel := context.WithTimeout(ctx, xai.SSOConversionTimeout)
defer cancel()
tokenResp, err := xai.ConvertSSOToBuild(requestCtx, ssoToken, &xai.SSODeviceOptions{HTTPClient: client})
if err != nil {
return nil, grokSSOConversionError(err)
}
return tokenResp, nil
}
func createGrokReqClient(proxyURL string) (*req.Client, error) {
return getSharedReqClient(reqClientOptions{
ProxyURL: proxyURL,
@@ -95,6 +112,43 @@ func createGrokReqClient(proxyURL string) (*req.Client, error) {
})
}
func createGrokSSOHTTPClient(proxyURL string) (*http.Client, error) {
client, err := sharedhttp.GetClient(sharedhttp.Options{
ProxyURL: proxyURL,
Timeout: xai.SSOConversionTimeout,
ResponseHeaderTimeout: 30 * time.Second,
})
if err != nil {
return nil, err
}
clone := *client
clone.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
return &clone, nil
}
func grokSSOConversionError(err error) error {
if errors.Is(err, xai.ErrSSOUnauthorized) {
return infraerrors.New(http.StatusUnauthorized, "GROK_SSO_UNAUTHORIZED", "Grok Web SSO cookie is invalid or expired")
}
if errors.Is(err, xai.ErrSSOAuthorizationDenied) {
return infraerrors.New(http.StatusForbidden, "GROK_SSO_AUTHORIZATION_DENIED", "xAI device authorization was denied or expired")
}
var statusErr xai.SSOHTTPError
if errors.As(err, &statusErr) {
statusCode := http.StatusBadGateway
if statusErr.Status == http.StatusForbidden {
statusCode = http.StatusForbidden
}
return infraerrors.Newf(statusCode, "GROK_SSO_UPSTREAM_FAILED", "xAI SSO conversion failed: %v", err)
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return infraerrors.Newf(http.StatusGatewayTimeout, "GROK_SSO_TIMEOUT", "xAI SSO conversion timed out: %v", err)
}
return infraerrors.Newf(http.StatusBadGateway, "GROK_SSO_CONVERSION_FAILED", "xAI SSO conversion failed: %v", err)
}
func grokOAuthStatusError(code, message string, resp *req.Response) error {
statusCode := http.StatusBadGateway
errorCode := code
+1
View File
@@ -399,6 +399,7 @@ func registerGrokOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
grok.POST("/oauth/exchange-code", h.Admin.GrokOAuth.ExchangeCode)
grok.POST("/oauth/refresh-token", h.Admin.GrokOAuth.RefreshToken)
grok.POST("/oauth/create-from-oauth", h.Admin.GrokOAuth.CreateAccountFromOAuth)
grok.POST("/sso-to-oauth", h.Admin.GrokOAuth.CreateAccountsFromSSO)
grok.POST("/accounts/:id/refresh", h.Admin.GrokOAuth.RefreshAccountToken)
grok.GET("/accounts/:id/quota", h.Admin.GrokOAuth.QueryQuota)
grok.POST("/accounts/:id/reset-quota", h.Admin.GrokOAuth.ResetQuota)
+50 -20
View File
@@ -3,8 +3,6 @@ package service
import (
"context"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"time"
@@ -101,6 +99,8 @@ type GrokTokenInfo struct {
ClientID string `json:"client_id,omitempty"`
Scope string `json:"scope,omitempty"`
Email string `json:"email,omitempty"`
Subject string `json:"sub,omitempty"`
TeamID string `json:"team_id,omitempty"`
SubscriptionTier string `json:"subscription_tier,omitempty"`
EntitlementStatus string `json:"entitlement_status,omitempty"`
}
@@ -175,6 +175,18 @@ 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) {
proxyURL, err := s.proxyURL(ctx, proxyID)
if err != nil {
return nil, err
}
tokenResp, err := s.oauthClient.ConvertSSOToBuild(ctx, ssoToken, proxyURL)
if err != nil {
return nil, err
}
return s.tokenInfoFromResponse(tokenResp, xai.DefaultClientID, nil), 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")
@@ -229,6 +241,12 @@ func (s *GrokOAuthService) BuildAccountCredentials(tokenInfo *GrokTokenInfo) map
if tokenInfo.Email != "" {
creds["email"] = tokenInfo.Email
}
if tokenInfo.Subject != "" {
creds["sub"] = tokenInfo.Subject
}
if tokenInfo.TeamID != "" {
creds["team_id"] = tokenInfo.TeamID
}
if tokenInfo.SubscriptionTier != "" {
creds["subscription_tier"] = tokenInfo.SubscriptionTier
}
@@ -265,12 +283,23 @@ func (s *GrokOAuthService) tokenInfoFromResponse(tokenResp *xai.TokenResponse, c
if info.TokenType == "" {
info.TokenType = "Bearer"
}
if email := parseJWTEmailClaim(tokenResp.IDToken); email != "" {
info.Email = email
}
if info.Email == "" && existing != nil {
if email, _ := existing["email"].(string); email != "" {
info.Email = email
applyGrokTokenClaims(info, tokenResp.IDToken)
applyGrokTokenClaims(info, tokenResp.AccessToken)
if existing != nil {
if info.Email == "" {
if email, _ := existing["email"].(string); email != "" {
info.Email = email
}
}
if info.Subject == "" {
if subject, _ := existing["sub"].(string); subject != "" {
info.Subject = subject
}
}
if info.TeamID == "" {
if teamID, _ := existing["team_id"].(string); teamID != "" {
info.TeamID = teamID
}
}
}
return info
@@ -293,20 +322,21 @@ func (s *GrokOAuthService) proxyURL(ctx context.Context, proxyID *int64) (string
return proxy.URL(), nil
}
func parseJWTEmailClaim(token string) string {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return ""
func applyGrokTokenClaims(info *GrokTokenInfo, token string) {
if info == nil || strings.TrimSpace(token) == "" {
return
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return ""
claims := xai.DecodeJWTClaims(token)
if claims == nil {
return
}
var claims struct {
Email string `json:"email"`
if info.Email == "" {
info.Email = xai.JWTClaimString(claims, "email")
}
if err := json.Unmarshal(payload, &claims); err != nil {
return ""
if info.Subject == "" {
info.Subject = xai.JWTClaimString(claims, "sub")
}
if info.TeamID == "" {
info.TeamID = xai.JWTClaimString(claims, "team_id")
}
return strings.TrimSpace(claims.Email)
}
@@ -4,6 +4,8 @@ package service
import (
"context"
"encoding/base64"
"encoding/json"
"testing"
"time"
@@ -13,6 +15,7 @@ import (
type grokOAuthClientStub struct {
refreshResponse *xai.TokenResponse
ssoResponse *xai.TokenResponse
exchangeCalls int
}
@@ -25,6 +28,10 @@ func (s *grokOAuthClientStub) RefreshToken(context.Context, string, string, stri
return s.refreshResponse, nil
}
func (s *grokOAuthClientStub) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) {
return s.ssoResponse, nil
}
func TestGrokOAuthServiceRefreshTokenPreservesOriginalRefreshTokenWhenNotRotated(t *testing.T) {
svc := NewGrokOAuthService(nil, &grokOAuthClientStub{
refreshResponse: &xai.TokenResponse{
@@ -79,3 +86,31 @@ func TestGrokOAuthServiceBuildAccountCredentialsDefaultsToSubscriptionProxy(t *t
require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"])
}
func TestGrokOAuthServiceConvertFromSSOExtractsBuildClaims(t *testing.T) {
svc := NewGrokOAuthService(nil, &grokOAuthClientStub{
ssoResponse: &xai.TokenResponse{
AccessToken: makeGrokOAuthJWT(map[string]any{"sub": "user-sub", "team_id": "team-1"}),
RefreshToken: "refresh-token",
IDToken: makeGrokOAuthJWT(map[string]any{"email": "user@example.com"}),
ExpiresIn: 3600,
},
})
defer svc.Stop()
info, err := svc.ConvertFromSSO(context.Background(), "sso-token", nil)
require.NoError(t, err)
require.Equal(t, "user@example.com", info.Email)
require.Equal(t, "user-sub", info.Subject)
require.Equal(t, "team-1", info.TeamID)
credentials := svc.BuildAccountCredentials(info)
require.Equal(t, "user@example.com", credentials["email"])
require.Equal(t, "user-sub", credentials["sub"])
require.Equal(t, "team-1", credentials["team_id"])
}
func makeGrokOAuthJWT(claims map[string]any) string {
payload, _ := json.Marshal(claims)
return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
}
@@ -22,6 +22,7 @@ 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)
ConvertSSOToBuild(ctx context.Context, ssoToken, proxyURL string) (*xai.TokenResponse, error)
}
// GrokOAuthTokenService is the narrow refresh port used by Grok token providers.
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { post } = vi.hoisted(() => ({
post: vi.fn(),
}))
vi.mock('@/api/client', () => ({
apiClient: { post },
}))
import { createFromSSO, getGrokSSOImportTimeout } from '@/api/admin/grok'
describe('admin Grok SSO import API', () => {
beforeEach(() => {
post.mockReset()
post.mockResolvedValue({ data: { created: [], failed: [] } })
})
it.each([
[1, 180_000],
[3, 180_000],
[4, 270_000],
[7, 360_000],
])('uses a timeout sized for %i keys', async (keyCount, expectedTimeout) => {
expect(getGrokSSOImportTimeout(keyCount)).toBe(expectedTimeout)
await createFromSSO({
sso_tokens: Array.from({ length: keyCount }, (_, index) => `sso-${index + 1}`),
})
expect(post).toHaveBeenCalledWith(
'/admin/grok/sso-to-oauth',
expect.objectContaining({ sso_tokens: expect.any(Array) }),
{ timeout: expectedTimeout },
)
})
})
+50 -1
View File
@@ -37,11 +37,51 @@ export interface GrokTokenInfo {
scope?: string
client_id?: string
email?: string
sub?: string
team_id?: string
subscription_tier?: string
entitlement_status?: string
[key: string]: unknown
}
export interface GrokSSOToOAuthRequest {
sso_tokens: string[]
name?: string
notes?: string | null
proxy_id?: number | null
group_ids?: number[]
credentials?: Record<string, unknown>
extra?: Record<string, unknown>
concurrency?: number
load_factor?: number
priority?: number
rate_multiplier?: number
expires_at?: number | null
auto_pause_on_expired?: boolean
}
export interface GrokSSOToOAuthItemResult {
index: number
name?: string
email?: string
account?: unknown
error?: string
}
export interface GrokSSOToOAuthResponse {
created: GrokSSOToOAuthItemResult[]
failed: GrokSSOToOAuthItemResult[]
}
const GROK_SSO_IMPORT_CONCURRENCY = 3
const GROK_SSO_IMPORT_TIMEOUT_PER_BATCH_MS = 90_000
const GROK_SSO_IMPORT_TIMEOUT_BUFFER_MS = 90_000
export function getGrokSSOImportTimeout(keyCount: number): number {
const batches = Math.ceil(Math.max(1, keyCount) / GROK_SSO_IMPORT_CONCURRENCY)
return batches * GROK_SSO_IMPORT_TIMEOUT_PER_BATCH_MS + GROK_SSO_IMPORT_TIMEOUT_BUFFER_MS
}
export interface GrokQuotaSnapshot {
requests?: GrokQuotaWindow | null
tokens?: GrokQuotaWindow | null
@@ -120,4 +160,13 @@ export async function resetQuota(id: number): Promise<GrokQuotaResetResult> {
return data
}
export default { generateAuthUrl, exchangeCode, refreshGrokToken, queryQuota, resetQuota }
export async function createFromSSO(payload: GrokSSOToOAuthRequest): Promise<GrokSSOToOAuthResponse> {
const { data } = await apiClient.post<GrokSSOToOAuthResponse>(
'/admin/grok/sso-to-oauth',
payload,
{ timeout: getGrokSSOImportTimeout(payload.sso_tokens.length) }
)
return data
}
export default { generateAuthUrl, exchangeCode, refreshGrokToken, queryQuota, resetQuota, createFromSSO }
@@ -50,7 +50,7 @@
<input
v-model="form.name"
type="text"
required
:required="!isGrokSSOInputMethod"
class="input"
:placeholder="t('admin.accounts.enterAccountName')"
data-tour="account-form-name"
@@ -1966,7 +1966,7 @@
<!-- OpenAI OAuth Model Mapping (OAuth 类型没有 apikey 容器需要独立的模型映射区域) -->
<div
v-if="(form.platform === 'openai' || form.platform === 'grok') && accountCategory === 'oauth-based'"
v-if="(form.platform === 'openai' || form.platform === 'grok') && isOAuthFlow"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
@@ -3118,6 +3118,9 @@
:show-access-token-option="false"
:show-codex-session-import-option="form.platform === 'openai'"
:show-codex-pat-option="form.platform === 'openai'"
:show-sso-option="form.platform === 'grok'"
:show-manual-option="true"
:initial-input-method="'manual'"
:platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@@ -3127,6 +3130,7 @@
@validate-session-token="handleValidateSessionToken"
@import-codex-session="handleOpenAIImportCodexSession"
@import-codex-pat="handleOpenAIImportCodexPAT"
@import-sso="handleGrokImportSSO"
/>
</div>
@@ -3524,6 +3528,7 @@ interface OAuthFlowExposed {
sessionToken: string
codexSession: string
codexPAT: string
ssoCookie: string
inputMethod: AuthInputMethod
reset: () => void
}
@@ -4015,6 +4020,8 @@ const isOAuthFlow = computed(() => {
return accountCategory.value === 'oauth-based'
})
const isGrokSSOInputMethod = computed(() => form.platform === 'grok' && oauthFlowRef.value?.inputMethod === 'sso_cookie')
const isManualInputMethod = computed(() => {
return oauthFlowRef.value?.inputMethod === 'manual'
})
@@ -4819,7 +4826,7 @@ const handleVertexServiceAccountDrop = async (event: DragEvent) => {
const handleSubmit = async () => {
// For OAuth-based type, handle OAuth flow (goes to step 2)
if (isOAuthFlow.value) {
if (!form.name.trim()) {
if (!isGrokSSOInputMethod.value && !form.name.trim()) {
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
return
}
@@ -5257,6 +5264,76 @@ const handleGrokValidateRT = async (refreshTokenInput: string) => {
}
}
const handleGrokImportSSO = async (ssoInput: string) => {
// Align with OpenAI/Grok RT batch import: one token per line, no client-side dedupe.
const ssoTokens = ssoInput
.split('\n')
.map((token) => token.trim())
.filter((token) => token)
if (ssoTokens.length === 0) return
grokOAuth.loading.value = true
grokOAuth.error.value = ''
const credentials: Record<string, unknown> = {}
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
if (modelMapping) {
credentials.model_mapping = modelMapping
}
if (!applyTempUnschedConfig(credentials)) {
grokOAuth.loading.value = false
return
}
try {
const result = await adminAPI.grok.createFromSSO({
sso_tokens: ssoTokens,
name: form.name || undefined,
notes: form.notes || undefined,
proxy_id: form.proxy_id,
group_ids: form.group_ids,
credentials,
concurrency: form.concurrency,
load_factor: form.load_factor ?? undefined,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
})
const successCount = result.created?.length || 0
const failedCount = result.failed?.length || 0
if (successCount > 0 && failedCount === 0) {
appStore.showSuccess(
ssoTokens.length > 1
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
: t('admin.accounts.accountCreated')
)
emit('created')
handleClose()
} else if (successCount > 0 && failedCount > 0) {
// Same as OpenAI/Grok RT: keep input, show failures, refresh list.
appStore.showWarning(
t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount })
)
grokOAuth.error.value = (result.failed || [])
.map((item) => `#${item.index}: ${item.error || 'Unknown error'}`)
.join('\n')
emit('created')
} else {
grokOAuth.error.value = (result.failed || [])
.map((item) => `#${item.index}: ${item.error || 'Unknown error'}`)
.join('\n') || t('admin.accounts.oauth.grok.failedToConvertSSO')
appStore.showError(t('admin.accounts.oauth.batchFailed'))
}
} catch (error: any) {
grokOAuth.error.value = error.response?.data?.detail || error.message || t('admin.accounts.oauth.grok.failedToConvertSSO')
appStore.showError(grokOAuth.error.value)
} finally {
grokOAuth.loading.value = false
}
}
// OpenAI OAuth 授权码兑换
const handleOpenAIExchange = async (authCode: string) => {
const oauthClient = openaiOAuth
@@ -15,7 +15,7 @@
{{ methodLabel }}
</label>
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center gap-2">
<label v-if="showManualOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
@@ -48,6 +48,17 @@
t(getOAuthKey('refreshTokenAuth'))
}}</span>
</label>
<label v-if="showSsoOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="sso_cookie"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t(getOAuthKey('ssoCookieAuth'))
}}</span>
</label>
<label v-if="showMobileRefreshTokenOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
@@ -190,6 +201,81 @@
</div>
</div>
<!-- SSO Cookie Input (Grok Web -> Grok Build) -->
<div v-if="inputMethod === 'sso_cookie'" class="space-y-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t(getOAuthKey('ssoCookieDesc')) }}
</p>
<div class="mb-4">
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<Icon name="key" size="sm" class="text-blue-500" />
{{ t(getOAuthKey('ssoCookieLabel')) }}
<span
v-if="parsedSSOCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ t('admin.accounts.oauth.keysCount', { count: parsedSSOCount }) }}
</span>
</label>
<textarea
v-model="ssoCookieInput"
rows="5"
class="input w-full resize-y font-mono text-sm"
:placeholder="t(getOAuthKey('ssoCookiePlaceholder'))"
spellcheck="false"
></textarea>
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400">
{{ t(getOAuthKey('ssoCookieHint')) }}
</p>
</div>
<div
v-if="error"
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
</div>
<button
type="button"
class="btn btn-primary w-full"
:disabled="loading || !ssoCookieInput.trim()"
@click="handleImportSSO"
>
<svg
v-if="loading"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="sparkles" size="sm" class="mr-2" />
{{ loading ? t(getOAuthKey('convertingSSO')) : t(getOAuthKey('convertSSOAndCreate')) }}
</button>
</div>
</div>
<!-- Codex OAuth/session JSON batch import -->
<div v-if="inputMethod === 'codex_session'" class="space-y-4">
<div
@@ -737,6 +823,9 @@ interface Props {
showAccessTokenOption?: boolean
showCodexSessionImportOption?: boolean
showCodexPatOption?: boolean
showSsoOption?: boolean
showManualOption?: boolean
initialInputMethod?: AuthInputMethod
platform?: AccountPlatform // Platform type for different UI/text
showProjectId?: boolean // New prop to control project ID visibility
}
@@ -757,6 +846,9 @@ const props = withDefaults(defineProps<Props>(), {
showAccessTokenOption: false,
showCodexSessionImportOption: false,
showCodexPatOption: false,
showSsoOption: false,
showManualOption: true,
initialInputMethod: 'manual',
platform: 'anthropic',
showProjectId: true
})
@@ -771,6 +863,7 @@ const emit = defineEmits<{
'import-access-token': [accessToken: string]
'import-codex-session': [content: string]
'import-codex-pat': [accessToken: string]
'import-sso': [content: string]
'update:inputMethod': [method: AuthInputMethod]
}>()
@@ -807,19 +900,31 @@ const oauthImportantNotice = computed(() => {
})
// Local state
const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
const inputMethod = ref<AuthInputMethod>(props.initialInputMethod)
const authCodeInput = ref('')
const sessionKeyInput = ref('')
const refreshTokenInput = ref('')
const sessionTokenInput = ref('')
const codexSessionInput = ref('')
const codexPATInput = ref('')
const ssoCookieInput = ref('')
const showHelpDialog = ref(false)
const oauthState = ref('')
const projectId = ref('')
// Computed: show method selection when either cookie or refresh token option is enabled
const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption || props.showCodexSessionImportOption || props.showCodexPatOption)
// Computed: show method selection only when there is something to choose.
const methodOptionCount = computed(() => [
props.showManualOption,
props.showCookieOption,
props.showRefreshTokenOption,
props.showMobileRefreshTokenOption,
props.showSessionTokenOption,
props.showAccessTokenOption,
props.showCodexSessionImportOption,
props.showCodexPatOption,
props.showSsoOption
].filter(Boolean).length)
const showMethodSelection = computed(() => methodOptionCount.value > 1)
// Clipboard
const { copied, copyToClipboard } = useClipboard()
@@ -850,7 +955,18 @@ const parsedCodexSessionCount = computed(() => {
.filter((item) => item).length
})
const parsedSSOCount = computed(() => {
return ssoCookieInput.value
.split('\n')
.map((item) => item.trim())
.filter((item) => item).length
})
// Watchers
watch(() => props.initialInputMethod, (newVal) => {
inputMethod.value = newVal
})
watch(inputMethod, (newVal) => {
emit('update:inputMethod', newVal)
})
@@ -933,6 +1049,12 @@ const handleImportCodexPAT = () => {
}
}
const handleImportSSO = () => {
if (ssoCookieInput.value.trim()) {
emit('import-sso', ssoCookieInput.value.trim())
}
}
// Expose methods and state
defineExpose({
authCode: authCodeInput,
@@ -943,6 +1065,7 @@ defineExpose({
sessionToken: sessionTokenInput,
codexSession: codexSessionInput,
codexPAT: codexPATInput,
ssoCookie: ssoCookieInput,
inputMethod,
reset: () => {
authCodeInput.value = ''
@@ -953,7 +1076,8 @@ defineExpose({
sessionTokenInput.value = ''
codexSessionInput.value = ''
codexPATInput.value = ''
inputMethod.value = 'manual'
ssoCookieInput.value = ''
inputMethod.value = props.initialInputMethod
showHelpDialog.value = false
}
})
+1 -1
View File
@@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
export type AddMethod = 'oauth' | 'setup-token'
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' | 'codex_session' | 'codex_pat'
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' | 'codex_session' | 'codex_pat' | 'sso_cookie'
export interface OAuthState {
authUrl: string
+2
View File
@@ -121,6 +121,8 @@ export function useGrokOAuth() {
client_id: tokenInfo.client_id,
scope: tokenInfo.scope,
email: tokenInfo.email,
sub: tokenInfo.sub,
team_id: tokenInfo.team_id,
subscription_tier: tokenInfo.subscription_tier,
entitlement_status: tokenInfo.entitlement_status,
base_url: 'https://cli-chat-proxy.grok.com/v1'
@@ -873,6 +873,13 @@ export default {
refreshTokenAuth: 'Manual RT Input',
refreshTokenDesc: 'Enter existing xAI refresh token(s). Supports batch input, one per line.',
refreshTokenPlaceholder: 'Paste your xAI refresh token...\nSupports multiple, one per line',
ssoCookieAuth: 'SSO Cookie Import',
ssoCookieDesc: 'Paste one Grok Web SSO key per line. The server will complete the xAI Device Flow and convert them into Grok Build OAuth credentials.',
ssoCookieLabel: 'Grok Web SSO Key',
ssoCookiePlaceholder: 'One SSO key per line\nSupports multiple, one per line',
ssoCookieHint: 'One SSO key per line. Multiple keys are imported with 3-way concurrency; expect about 90 seconds per batch. Use a matching-region proxy if needed.',
convertingSSO: 'Converting...',
convertSSOAndCreate: 'Convert & Create Account',
validating: 'Validating...',
validateAndCreate: 'Validate & Create Account',
pleaseEnterRefreshToken: 'Please enter Refresh Token',
@@ -880,6 +887,7 @@ export default {
missingExchangeParams: 'Missing authorization code, state, or OAuth session',
failedToExchangeCode: 'Failed to exchange Grok authorization code',
failedToValidateRT: 'Failed to validate Grok refresh token',
failedToConvertSSO: 'Failed to convert Grok SSO cookie',
errors: {
GROK_OAUTH_SESSION_NOT_FOUND:
'Grok OAuth session was not found or has expired. Generate a new auth URL and paste the newest callback URL.',
@@ -959,6 +959,13 @@ export default {
refreshTokenAuth: '手动输入 RT',
refreshTokenDesc: '输入已有的 xAI refresh token,支持批量输入(每行一个)。',
refreshTokenPlaceholder: '粘贴您的 xAI refresh token...\n支持多个,每行一个',
ssoCookieAuth: 'SSO Cookie 导入',
ssoCookieDesc: '每行粘贴一个 Grok Web SSO key,系统会自动走 xAI Device Flow 并转换为 Grok Build OAuth 凭据。',
ssoCookieLabel: 'Grok Web SSO Key',
ssoCookiePlaceholder: '每行一个 SSO key\n支持多个,每行一个',
ssoCookieHint: '每行一个 SSO key;多个 key 会 3 路并发导入,耗时约 90 秒 × 批次数,建议使用对应地区代理。',
convertingSSO: '转换中...',
convertSSOAndCreate: '转换并创建账号',
validating: '验证中...',
validateAndCreate: '验证并创建账号',
pleaseEnterRefreshToken: '请输入 Refresh Token',
@@ -966,6 +973,7 @@ export default {
missingExchangeParams: '缺少授权码、state 或 OAuth 会话',
failedToExchangeCode: 'Grok 授权码兑换失败',
failedToValidateRT: '验证 Grok refresh token 失败',
failedToConvertSSO: 'Grok SSO 转换失败',
errors: {
GROK_OAUTH_SESSION_NOT_FOUND:
'Grok OAuth 会话不存在或已过期。请重新生成授权链接,并粘贴最新的回调链接。',