mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
feat(grok): 支持 Web SSO 批量导入并转换为 Build OAuth
新增 Grok Web SSO → xAI Device Flow → Grok Build OAuth 导入链路, 支持管理员批量粘贴 SSO key 创建 OAuth 账号。 - 后端:ConvertSSOToBuild、ConvertFromSSO、POST /admin/grok/sso-to-oauth - 批量:3 worker 并发,失败跳过并汇总 created/failed,worker panic recover - 无 refresh_token 时写入 expires_at 并强制 auto_pause_on_expired - 前端:SSO Cookie 导入入口、动态超时、中英文案、部分成功不关弹窗 - 测试:pkg/service/handler/前端超时单测;本地 Docker 真实 SSO e2e 通过
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -145,3 +145,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")
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -34,11 +34,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 GrokQuotaWindow {
|
||||
limit?: number | null
|
||||
remaining?: number | null
|
||||
@@ -119,4 +159,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="!isGrokSSOImport"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.enterAccountName')"
|
||||
data-tour="account-form-name"
|
||||
@@ -355,7 +355,7 @@
|
||||
<!-- Account Type Selection (Grok) -->
|
||||
<div v-if="form.platform === 'grok'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2" data-tour="account-form-type">
|
||||
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3" data-tour="account-form-type">
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'oauth-based'"
|
||||
@@ -382,6 +382,36 @@
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'sso_cookie'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
accountCategory === 'sso_cookie'
|
||||
? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
|
||||
: 'border-gray-200 hover:border-teal-300 dark:border-dark-600 dark:hover:border-teal-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
accountCategory === 'sso_cookie'
|
||||
? 'bg-teal-500 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
<Icon name="link" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.oauth.grok.ssoCookieAuth') }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.oauth.grok.ssoCookieHint') }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="grok-account-type-api-key"
|
||||
@@ -1966,7 +1996,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>
|
||||
@@ -3080,12 +3110,15 @@
|
||||
:show-proxy-warning="form.platform !== 'openai' && form.platform !== 'grok' && !!form.proxy_id"
|
||||
:allow-multiple="form.platform === 'anthropic'"
|
||||
:show-cookie-option="form.platform === 'anthropic'"
|
||||
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity' || form.platform === 'grok'"
|
||||
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity' || (form.platform === 'grok' && !isGrokSSOImport)"
|
||||
:show-mobile-refresh-token-option="form.platform === 'openai'"
|
||||
:show-session-token-option="false"
|
||||
: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="!isGrokSSOImport"
|
||||
:initial-input-method="initialOAuthInputMethod"
|
||||
:platform="form.platform"
|
||||
:show-project-id="geminiOAuthType === 'code_assist'"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@@ -3095,6 +3128,7 @@
|
||||
@validate-session-token="handleValidateSessionToken"
|
||||
@import-codex-session="handleOpenAIImportCodexSession"
|
||||
@import-codex-pat="handleOpenAIImportCodexPAT"
|
||||
@import-sso="handleGrokImportSSO"
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -3492,6 +3526,7 @@ interface OAuthFlowExposed {
|
||||
sessionToken: string
|
||||
codexSession: string
|
||||
codexPAT: string
|
||||
ssoCookie: string
|
||||
inputMethod: AuthInputMethod
|
||||
reset: () => void
|
||||
}
|
||||
@@ -3595,7 +3630,7 @@ interface TempUnschedRuleForm {
|
||||
// State
|
||||
const step = ref(1)
|
||||
const submitting = ref(false)
|
||||
const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock' | 'service_account'>('oauth-based') // UI selection for account category
|
||||
const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock' | 'service_account' | 'sso_cookie'>('oauth-based') // UI selection for account category
|
||||
const addMethod = ref<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-token'
|
||||
const apiKeyBaseUrl = ref('https://api.anthropic.com')
|
||||
const apiKeyValue = ref('')
|
||||
@@ -3973,9 +4008,13 @@ const isOAuthFlow = computed(() => {
|
||||
if (form.platform === 'anthropic' && accountCategory.value === 'bedrock') {
|
||||
return false
|
||||
}
|
||||
return accountCategory.value === 'oauth-based'
|
||||
return accountCategory.value === 'oauth-based' || (form.platform === 'grok' && accountCategory.value === 'sso_cookie')
|
||||
})
|
||||
|
||||
const isGrokSSOImport = computed(() => form.platform === 'grok' && accountCategory.value === 'sso_cookie')
|
||||
|
||||
const initialOAuthInputMethod = computed<AuthInputMethod>(() => isGrokSSOImport.value ? 'sso_cookie' : 'manual')
|
||||
|
||||
const isManualInputMethod = computed(() => {
|
||||
return oauthFlowRef.value?.inputMethod === 'manual'
|
||||
})
|
||||
@@ -4049,7 +4088,7 @@ watch(
|
||||
}
|
||||
if ((form.platform === 'gemini' || form.platform === 'anthropic') && category === 'service_account') {
|
||||
form.type = 'service_account' as AccountType
|
||||
} else if (category === 'oauth-based') {
|
||||
} else if (category === 'oauth-based' || (form.platform === 'grok' && category === 'sso_cookie')) {
|
||||
form.type = form.platform === 'anthropic' ? method as AccountType : 'oauth'
|
||||
} else {
|
||||
form.type = 'apikey'
|
||||
@@ -4103,6 +4142,9 @@ watch(
|
||||
if (newPlatform !== 'anthropic' && accountCategory.value === 'bedrock') {
|
||||
accountCategory.value = 'oauth-based'
|
||||
}
|
||||
if (newPlatform !== 'grok' && accountCategory.value === 'sso_cookie') {
|
||||
accountCategory.value = 'oauth-based'
|
||||
}
|
||||
// Reset Bedrock fields when switching platforms
|
||||
bedrockAccessKeyId.value = ''
|
||||
bedrockSecretAccessKey.value = ''
|
||||
@@ -4766,7 +4808,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 (!isGrokSSOImport.value && !form.name.trim()) {
|
||||
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
|
||||
return
|
||||
}
|
||||
@@ -5204,6 +5246,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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -870,6 +870,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',
|
||||
@@ -877,6 +884,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.',
|
||||
|
||||
@@ -956,6 +956,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',
|
||||
@@ -963,6 +970,7 @@ export default {
|
||||
missingExchangeParams: '缺少授权码、state 或 OAuth 会话',
|
||||
failedToExchangeCode: 'Grok 授权码兑换失败',
|
||||
failedToValidateRT: '验证 Grok refresh token 失败',
|
||||
failedToConvertSSO: 'Grok SSO 转换失败',
|
||||
errors: {
|
||||
GROK_OAUTH_SESSION_NOT_FOUND:
|
||||
'Grok OAuth 会话不存在或已过期。请重新生成授权链接,并粘贴最新的回调链接。',
|
||||
|
||||
Reference in New Issue
Block a user