Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
106
backend/internal/auth/xai/token.go
Normal file
106
backend/internal/auth/xai/token.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package xai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// TokenStorage stores xAI OAuth credentials on disk.
|
||||
type TokenStorage struct {
|
||||
Type string `json:"type"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
Expire string `json:"expired,omitempty"`
|
||||
LastRefresh string `json:"last_refresh,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri,omitempty"`
|
||||
TokenEndpoint string `json:"token_endpoint,omitempty"`
|
||||
AuthKind string `json:"auth_kind,omitempty"`
|
||||
|
||||
Metadata map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
// SetMetadata allows the token store to merge status fields before saving.
|
||||
func (ts *TokenStorage) SetMetadata(meta map[string]any) {
|
||||
ts.Metadata = meta
|
||||
}
|
||||
|
||||
// SaveTokenToFile writes xAI credentials to a JSON auth file.
|
||||
func (ts *TokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
ts.Type = "xai"
|
||||
ts.AuthKind = "oauth"
|
||||
if errMkdirAll := os.MkdirAll(filepath.Dir(authFilePath), 0o700); errMkdirAll != nil {
|
||||
return fmt.Errorf("xai token storage: create directory: %w", errMkdirAll)
|
||||
}
|
||||
|
||||
data, errMerge := misc.MergeMetadata(ts, ts.Metadata)
|
||||
if errMerge != nil {
|
||||
return fmt.Errorf("xai token storage: merge metadata: %w", errMerge)
|
||||
}
|
||||
|
||||
file, err := os.Create(authFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("xai token storage: create token file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := file.Close(); errClose != nil {
|
||||
log.Errorf("xai token storage: close token file error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
if err = encoder.Encode(data); err != nil {
|
||||
return fmt.Errorf("xai token storage: write token file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CredentialFileName returns the filename used for xAI credentials.
|
||||
func CredentialFileName(email, subject string) string {
|
||||
email = sanitizeFileSegment(email)
|
||||
if email != "" {
|
||||
return fmt.Sprintf("xai-%s.json", email)
|
||||
}
|
||||
subject = sanitizeFileSegment(subject)
|
||||
if subject != "" {
|
||||
return fmt.Sprintf("xai-%s.json", subject)
|
||||
}
|
||||
return fmt.Sprintf("xai-%d.json", time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
func sanitizeFileSegment(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '@' || r == '.' || r == '_' || r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
75
backend/internal/auth/xai/types.go
Normal file
75
backend/internal/auth/xai/types.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// Package xai provides OAuth2 authentication helpers for xAI Grok.
|
||||
package xai
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// DefaultAPIBaseURL is the default official xAI API base URL.
|
||||
// Used for OAuth credential defaults, websocket, media (image/video),
|
||||
// and non-media HTTP chat when auth using_api is true or non-OAuth.
|
||||
DefaultAPIBaseURL = "https://api.x.ai/v1"
|
||||
// CLIChatProxyBaseURL is the Grok CLI chat-proxy base URL for non-image/video
|
||||
// HTTP chat when auth using_api is false, including the OAuth default.
|
||||
CLIChatProxyBaseURL = "https://cli-chat-proxy.grok.com/v1"
|
||||
// Issuer is xAI's OAuth issuer.
|
||||
Issuer = "https://auth.x.ai"
|
||||
// DiscoveryURL is the OIDC discovery endpoint used to resolve OAuth endpoints.
|
||||
DiscoveryURL = Issuer + "/.well-known/openid-configuration"
|
||||
// ClientID is the public xAI Grok CLI OAuth client ID.
|
||||
ClientID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
// Scope is the OAuth scope set required for xAI API access.
|
||||
Scope = "openid profile email offline_access grok-cli:access api:access"
|
||||
// DeviceCodeGrantType is the OAuth2 device authorization grant type (RFC 8628).
|
||||
DeviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
// defaultPollInterval is used when the device endpoint omits interval.
|
||||
defaultPollInterval = 5 * time.Second
|
||||
// httpClientTimeout bounds credential-acquisition HTTP calls (device/token/refresh).
|
||||
httpClientTimeout = 30 * time.Second
|
||||
// MaxPollDuration is the upper bound for waiting on user authorization.
|
||||
MaxPollDuration = 30 * time.Minute
|
||||
)
|
||||
|
||||
var refreshLead = 5 * time.Minute
|
||||
|
||||
// RefreshLead returns the refresh lead time for xAI OAuth credentials.
|
||||
func RefreshLead() time.Duration {
|
||||
return refreshLead
|
||||
}
|
||||
|
||||
// Discovery contains OAuth endpoints resolved from xAI OIDC discovery.
|
||||
type Discovery struct {
|
||||
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
}
|
||||
|
||||
// DeviceCodeResponse represents xAI's device authorization response.
|
||||
type DeviceCodeResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
TokenEndpoint string `json:"-"`
|
||||
}
|
||||
|
||||
// TokenData holds xAI OAuth token data.
|
||||
type TokenData struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
Expire string `json:"expired,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
}
|
||||
|
||||
// AuthBundle aggregates token data and OAuth metadata for persistence.
|
||||
type AuthBundle struct {
|
||||
TokenData TokenData
|
||||
LastRefresh string
|
||||
BaseURL string
|
||||
RedirectURI string
|
||||
TokenEndpoint string
|
||||
}
|
||||
483
backend/internal/auth/xai/xai.go
Normal file
483
backend/internal/auth/xai/xai.go
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
package xai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// XAIAuth performs xAI OAuth discovery, device-code login, and refresh.
|
||||
type XAIAuth struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var xaiRefreshGroup singleflight.Group
|
||||
|
||||
// NewXAIAuth creates an xAI OAuth helper using config proxy settings.
|
||||
func NewXAIAuth(cfg *config.Config) *XAIAuth {
|
||||
return NewXAIAuthWithProxyURL(cfg, "")
|
||||
}
|
||||
|
||||
// NewXAIAuthWithProxyURL creates an xAI OAuth helper with an explicit proxy URL.
|
||||
func NewXAIAuthWithProxyURL(cfg *config.Config, proxyURL string) *XAIAuth {
|
||||
effectiveProxyURL := strings.TrimSpace(proxyURL)
|
||||
var sdkCfg config.SDKConfig
|
||||
if cfg != nil {
|
||||
sdkCfg = cfg.SDKConfig
|
||||
if effectiveProxyURL == "" {
|
||||
effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL)
|
||||
}
|
||||
}
|
||||
sdkCfg.ProxyURL = effectiveProxyURL
|
||||
return &XAIAuth{httpClient: util.SetProxy(&sdkCfg, &http.Client{Timeout: httpClientTimeout})}
|
||||
}
|
||||
|
||||
// ValidateOAuthEndpoint validates an endpoint returned by xAI discovery.
|
||||
func ValidateOAuthEndpoint(rawURL string, field string) (string, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return "", fmt.Errorf("xai discovery %s is empty", field)
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("xai discovery %s is invalid: %w", field, err)
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return "", fmt.Errorf("xai discovery %s must use https: %q", field, rawURL)
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
||||
if host != "x.ai" && !strings.HasSuffix(host, ".x.ai") {
|
||||
return "", fmt.Errorf("xai discovery %s host %q is not on x.ai", field, host)
|
||||
}
|
||||
return rawURL, nil
|
||||
}
|
||||
|
||||
// Discover resolves xAI OAuth endpoints through OIDC discovery.
|
||||
func (a *XAIAuth) Discover(ctx context.Context) (*Discovery, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, DiscoveryURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai discovery: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai discovery: request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("xai discovery: close response body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai discovery: read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("xai discovery failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var payload struct {
|
||||
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, fmt.Errorf("xai discovery: parse response: %w", err)
|
||||
}
|
||||
deviceAuthorizationEndpoint, err := ValidateOAuthEndpoint(payload.DeviceAuthorizationEndpoint, "device_authorization_endpoint")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenEndpoint, err := ValidateOAuthEndpoint(payload.TokenEndpoint, "token_endpoint")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Discovery{
|
||||
DeviceAuthorizationEndpoint: deviceAuthorizationEndpoint,
|
||||
TokenEndpoint: tokenEndpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StartDeviceFlow requests a device code from xAI.
|
||||
func (a *XAIAuth) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) {
|
||||
discovery, errDiscover := a.Discover(ctx)
|
||||
if errDiscover != nil {
|
||||
return nil, errDiscover
|
||||
}
|
||||
return a.RequestDeviceCode(ctx, discovery.DeviceAuthorizationEndpoint, discovery.TokenEndpoint)
|
||||
}
|
||||
|
||||
// RequestDeviceCode requests a device authorization code from the given endpoint.
|
||||
func (a *XAIAuth) RequestDeviceCode(ctx context.Context, deviceAuthorizationEndpoint, tokenEndpoint string) (*DeviceCodeResponse, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
deviceAuthorizationEndpoint = strings.TrimSpace(deviceAuthorizationEndpoint)
|
||||
if deviceAuthorizationEndpoint == "" {
|
||||
return nil, fmt.Errorf("xai device code: device authorization endpoint is required")
|
||||
}
|
||||
|
||||
form := url.Values{
|
||||
"client_id": {ClientID},
|
||||
"scope": {Scope},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, deviceAuthorizationEndpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai device code: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai device code request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("xai device code: close response body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai device code: read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("xai device code request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var deviceCode DeviceCodeResponse
|
||||
if err = json.Unmarshal(body, &deviceCode); err != nil {
|
||||
return nil, fmt.Errorf("xai device code: parse response: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(deviceCode.DeviceCode) == "" {
|
||||
return nil, fmt.Errorf("xai device code: response missing device_code")
|
||||
}
|
||||
if strings.TrimSpace(deviceCode.UserCode) == "" {
|
||||
return nil, fmt.Errorf("xai device code: response missing user_code")
|
||||
}
|
||||
if strings.TrimSpace(deviceCode.VerificationURI) == "" && strings.TrimSpace(deviceCode.VerificationURIComplete) == "" {
|
||||
return nil, fmt.Errorf("xai device code: response missing verification URI")
|
||||
}
|
||||
deviceCode.TokenEndpoint = strings.TrimSpace(tokenEndpoint)
|
||||
return &deviceCode, nil
|
||||
}
|
||||
|
||||
// WaitForAuthorization polls until the user authorizes the device code and returns tokens.
|
||||
func (a *XAIAuth) WaitForAuthorization(ctx context.Context, deviceCode *DeviceCodeResponse) (*AuthBundle, error) {
|
||||
tokenData, err := a.PollForToken(ctx, deviceCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenEndpoint := ""
|
||||
if deviceCode != nil {
|
||||
tokenEndpoint = strings.TrimSpace(deviceCode.TokenEndpoint)
|
||||
}
|
||||
return &AuthBundle{
|
||||
TokenData: *tokenData,
|
||||
LastRefresh: time.Now().UTC().Format(time.RFC3339),
|
||||
BaseURL: DefaultAPIBaseURL,
|
||||
TokenEndpoint: tokenEndpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PollForToken polls the token endpoint until the user authorizes or the device code expires.
|
||||
func (a *XAIAuth) PollForToken(ctx context.Context, deviceCode *DeviceCodeResponse) (*TokenData, error) {
|
||||
if deviceCode == nil {
|
||||
return nil, fmt.Errorf("xai device code: response is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
tokenEndpoint := strings.TrimSpace(deviceCode.TokenEndpoint)
|
||||
if tokenEndpoint == "" {
|
||||
discovery, errDiscover := a.Discover(ctx)
|
||||
if errDiscover != nil {
|
||||
return nil, errDiscover
|
||||
}
|
||||
tokenEndpoint = discovery.TokenEndpoint
|
||||
}
|
||||
|
||||
interval := time.Duration(deviceCode.Interval) * time.Second
|
||||
if interval < defaultPollInterval {
|
||||
interval = defaultPollInterval
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(MaxPollDuration)
|
||||
if deviceCode.ExpiresIn > 0 {
|
||||
codeDeadline := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)
|
||||
if codeDeadline.Before(deadline) {
|
||||
deadline = codeDeadline
|
||||
}
|
||||
}
|
||||
|
||||
// Poll immediately once, then wait between subsequent attempts.
|
||||
firstAttempt := true
|
||||
timer := time.NewTimer(0)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("xai device code: context cancelled: %w", ctx.Err())
|
||||
case <-timer.C:
|
||||
if !firstAttempt && time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("xai device code expired")
|
||||
}
|
||||
firstAttempt = false
|
||||
|
||||
token, pollErr, nextInterval, shouldContinue := a.exchangeDeviceCode(ctx, tokenEndpoint, deviceCode.DeviceCode, interval)
|
||||
if token != nil {
|
||||
return token, nil
|
||||
}
|
||||
if !shouldContinue {
|
||||
return nil, pollErr
|
||||
}
|
||||
interval = nextInterval
|
||||
timer.Reset(interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// exchangeDeviceCode attempts to exchange a device code for tokens.
|
||||
// Returns (token, error, nextInterval, shouldContinue).
|
||||
func (a *XAIAuth) exchangeDeviceCode(ctx context.Context, tokenEndpoint, deviceCode string, interval time.Duration) (*TokenData, error, time.Duration, bool) {
|
||||
form := url.Values{
|
||||
"grant_type": {DeviceCodeGrantType},
|
||||
"device_code": {strings.TrimSpace(deviceCode)},
|
||||
"client_id": {ClientID},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(tokenEndpoint), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai device token: create request: %w", err), interval, false
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai device token request failed: %w", err), interval, false
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("xai device token: close response body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai device token: read response: %w", err), interval, false
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, fmt.Errorf("xai device token: parse response: %w", err), interval, false
|
||||
}
|
||||
|
||||
if payload.Error != "" {
|
||||
switch payload.Error {
|
||||
case "authorization_pending":
|
||||
return nil, nil, interval, true
|
||||
case "slow_down":
|
||||
nextInterval := interval + defaultPollInterval
|
||||
return nil, nil, nextInterval, true
|
||||
case "expired_token":
|
||||
return nil, fmt.Errorf("xai device code expired"), interval, false
|
||||
case "access_denied":
|
||||
return nil, fmt.Errorf("xai device authorization denied"), interval, false
|
||||
default:
|
||||
desc := strings.TrimSpace(payload.ErrorDescription)
|
||||
if desc != "" {
|
||||
return nil, fmt.Errorf("xai device token error: %s: %s", payload.Error, desc), interval, false
|
||||
}
|
||||
return nil, fmt.Errorf("xai device token error: %s", payload.Error), interval, false
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("xai device token request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))), interval, false
|
||||
}
|
||||
if strings.TrimSpace(payload.AccessToken) == "" {
|
||||
return nil, fmt.Errorf("xai device token response missing access_token"), interval, false
|
||||
}
|
||||
|
||||
email, subject := parseJWTIdentity(payload.IDToken)
|
||||
return buildTokenData(payload.AccessToken, payload.RefreshToken, payload.IDToken, payload.TokenType, payload.ExpiresIn, email, subject), nil, interval, false
|
||||
}
|
||||
|
||||
// RefreshTokens refreshes an xAI access token.
|
||||
func (a *XAIAuth) RefreshTokens(ctx context.Context, refreshToken, tokenEndpoint string) (*TokenData, error) {
|
||||
if strings.TrimSpace(refreshToken) == "" {
|
||||
return nil, fmt.Errorf("xai token refresh: refresh token is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
refreshToken = strings.TrimSpace(refreshToken)
|
||||
if strings.TrimSpace(tokenEndpoint) == "" {
|
||||
discovery, errDiscover := a.Discover(ctx)
|
||||
if errDiscover != nil {
|
||||
return nil, errDiscover
|
||||
}
|
||||
tokenEndpoint = discovery.TokenEndpoint
|
||||
}
|
||||
tokenEndpoint = strings.TrimSpace(tokenEndpoint)
|
||||
|
||||
result, err, _ := xaiRefreshGroup.Do(refreshToken, func() (interface{}, error) {
|
||||
return a.refreshTokensSingleFlight(context.WithoutCancel(ctx), refreshToken, tokenEndpoint)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenData, ok := result.(*TokenData)
|
||||
if !ok || tokenData == nil {
|
||||
return nil, fmt.Errorf("xai token refresh failed: invalid single-flight result")
|
||||
}
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
func (a *XAIAuth) refreshTokensSingleFlight(ctx context.Context, refreshToken, tokenEndpoint string) (*TokenData, error) {
|
||||
form := url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"client_id": {ClientID},
|
||||
"refresh_token": {refreshToken},
|
||||
}
|
||||
return a.postTokenForm(ctx, tokenEndpoint, form)
|
||||
}
|
||||
|
||||
func (a *XAIAuth) postTokenForm(ctx context.Context, tokenEndpoint string, form url.Values) (*TokenData, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(tokenEndpoint), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai token request: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai token request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("xai token request: close response body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai token response: read body: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("xai token request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var payload struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, fmt.Errorf("xai token response: parse body: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(payload.AccessToken) == "" {
|
||||
return nil, fmt.Errorf("xai token response missing access_token")
|
||||
}
|
||||
email, subject := parseJWTIdentity(payload.IDToken)
|
||||
return buildTokenData(payload.AccessToken, payload.RefreshToken, payload.IDToken, payload.TokenType, payload.ExpiresIn, email, subject), nil
|
||||
}
|
||||
|
||||
// CreateTokenStorage converts an auth bundle into persistable storage.
|
||||
func (a *XAIAuth) CreateTokenStorage(bundle *AuthBundle) *TokenStorage {
|
||||
if bundle == nil {
|
||||
return nil
|
||||
}
|
||||
return &TokenStorage{
|
||||
Type: "xai",
|
||||
AccessToken: bundle.TokenData.AccessToken,
|
||||
RefreshToken: bundle.TokenData.RefreshToken,
|
||||
IDToken: bundle.TokenData.IDToken,
|
||||
TokenType: bundle.TokenData.TokenType,
|
||||
ExpiresIn: bundle.TokenData.ExpiresIn,
|
||||
Expire: bundle.TokenData.Expire,
|
||||
LastRefresh: bundle.LastRefresh,
|
||||
Email: strings.TrimSpace(bundle.TokenData.Email),
|
||||
Subject: bundle.TokenData.Subject,
|
||||
BaseURL: firstNonEmpty(bundle.BaseURL, DefaultAPIBaseURL),
|
||||
RedirectURI: bundle.RedirectURI,
|
||||
TokenEndpoint: bundle.TokenEndpoint,
|
||||
AuthKind: "oauth",
|
||||
}
|
||||
}
|
||||
|
||||
func buildTokenData(accessToken, refreshToken, idToken, tokenType string, expiresIn int, email, subject string) *TokenData {
|
||||
tokenData := &TokenData{
|
||||
AccessToken: strings.TrimSpace(accessToken),
|
||||
RefreshToken: strings.TrimSpace(refreshToken),
|
||||
IDToken: strings.TrimSpace(idToken),
|
||||
TokenType: strings.TrimSpace(tokenType),
|
||||
ExpiresIn: expiresIn,
|
||||
Email: email,
|
||||
Subject: subject,
|
||||
}
|
||||
if expiresIn > 0 {
|
||||
tokenData.Expire = time.Now().Add(time.Duration(expiresIn) * time.Second).UTC().Format(time.RFC3339)
|
||||
}
|
||||
return tokenData
|
||||
}
|
||||
|
||||
func parseJWTIdentity(token string) (email string, subject string) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return "", ""
|
||||
}
|
||||
payload := parts[1]
|
||||
payload += strings.Repeat("=", (4-len(payload)%4)%4)
|
||||
raw, err := base64.URLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
var claims map[string]any
|
||||
if err = json.Unmarshal(raw, &claims); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
if v, ok := claims["email"].(string); ok {
|
||||
email = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := claims["sub"].(string); ok {
|
||||
subject = strings.TrimSpace(v)
|
||||
}
|
||||
return email, subject
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
327
backend/internal/auth/xai/xai_auth_test.go
Normal file
327
backend/internal/auth/xai/xai_auth_test.go
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
package xai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
func resetXAIRefreshGroupForTest() {
|
||||
xaiRefreshGroup = singleflight.Group{}
|
||||
}
|
||||
|
||||
func TestValidateOAuthEndpointRejectsNonXAIOrigin(t *testing.T) {
|
||||
if _, err := ValidateOAuthEndpoint("https://auth.x.ai/oauth2/token", "token_endpoint"); err != nil {
|
||||
t.Fatalf("ValidateOAuthEndpoint(xai) error = %v", err)
|
||||
}
|
||||
if _, err := ValidateOAuthEndpoint("http://auth.x.ai/oauth2/token", "token_endpoint"); err == nil {
|
||||
t.Fatal("expected non-HTTPS endpoint to be rejected")
|
||||
}
|
||||
if _, err := ValidateOAuthEndpoint("https://evil.example/oauth/token", "token_endpoint"); err == nil {
|
||||
t.Fatal("expected non-xAI endpoint to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDeviceCodePostsClientIDAndScope(t *testing.T) {
|
||||
var gotForm url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/x-www-form-urlencoded") {
|
||||
t.Fatalf("Content-Type = %q, want form", got)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm() error = %v", err)
|
||||
}
|
||||
gotForm = r.PostForm
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"device_code": "device-abc",
|
||||
"user_code": "ABCD-1234",
|
||||
"verification_uri": "https://accounts.x.ai/oauth2/device",
|
||||
"verification_uri_complete": "https://accounts.x.ai/oauth2/device?user_code=ABCD-1234",
|
||||
"expires_in": 1800,
|
||||
"interval": 5,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
auth := NewXAIAuth(nil)
|
||||
deviceCode, err := auth.RequestDeviceCode(context.Background(), server.URL, "https://auth.x.ai/oauth2/token")
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceCode() error = %v", err)
|
||||
}
|
||||
if deviceCode.DeviceCode != "device-abc" {
|
||||
t.Fatalf("device_code = %q, want device-abc", deviceCode.DeviceCode)
|
||||
}
|
||||
if deviceCode.UserCode != "ABCD-1234" {
|
||||
t.Fatalf("user_code = %q, want ABCD-1234", deviceCode.UserCode)
|
||||
}
|
||||
if deviceCode.TokenEndpoint != "https://auth.x.ai/oauth2/token" {
|
||||
t.Fatalf("TokenEndpoint = %q", deviceCode.TokenEndpoint)
|
||||
}
|
||||
if gotForm.Get("client_id") != ClientID {
|
||||
t.Fatalf("client_id = %q, want %q", gotForm.Get("client_id"), ClientID)
|
||||
}
|
||||
if gotForm.Get("scope") != Scope {
|
||||
t.Fatalf("scope = %q, want %q", gotForm.Get("scope"), Scope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollForTokenExchangesDeviceCode(t *testing.T) {
|
||||
var pollCount int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm() error = %v", err)
|
||||
}
|
||||
if got := r.PostForm.Get("grant_type"); got != DeviceCodeGrantType {
|
||||
t.Fatalf("grant_type = %q, want %q", got, DeviceCodeGrantType)
|
||||
}
|
||||
if got := r.PostForm.Get("device_code"); got != "device-abc" {
|
||||
t.Fatalf("device_code = %q, want device-abc", got)
|
||||
}
|
||||
if got := r.PostForm.Get("client_id"); got != ClientID {
|
||||
t.Fatalf("client_id = %q, want %q", got, ClientID)
|
||||
}
|
||||
|
||||
count := atomic.AddInt32(&pollCount, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if count == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "authorization_pending",
|
||||
"error_description": "User has not yet authorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "access-1",
|
||||
"refresh_token": "refresh-1",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"id_token": fakeJWTWithEmail("user@x.ai", "sub-1"),
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
auth := NewXAIAuth(nil)
|
||||
tokenData, err := auth.PollForToken(context.Background(), &DeviceCodeResponse{
|
||||
DeviceCode: "device-abc",
|
||||
UserCode: "ABCD-1234",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
TokenEndpoint: server.URL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PollForToken() error = %v", err)
|
||||
}
|
||||
if tokenData.AccessToken != "access-1" {
|
||||
t.Fatalf("access token = %q, want access-1", tokenData.AccessToken)
|
||||
}
|
||||
if tokenData.RefreshToken != "refresh-1" {
|
||||
t.Fatalf("refresh token = %q, want refresh-1", tokenData.RefreshToken)
|
||||
}
|
||||
if tokenData.Email != "user@x.ai" {
|
||||
t.Fatalf("email = %q, want user@x.ai", tokenData.Email)
|
||||
}
|
||||
if tokenData.Subject != "sub-1" {
|
||||
t.Fatalf("subject = %q, want sub-1", tokenData.Subject)
|
||||
}
|
||||
if got := atomic.LoadInt32(&pollCount); got != 2 {
|
||||
t.Fatalf("poll count = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollForTokenAccessDenied(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "access_denied",
|
||||
"error_description": "The user rejected the request",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
auth := NewXAIAuth(nil)
|
||||
_, err := auth.PollForToken(context.Background(), &DeviceCodeResponse{
|
||||
DeviceCode: "device-abc",
|
||||
UserCode: "ABCD-1234",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
TokenEndpoint: server.URL,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "authorization denied") {
|
||||
t.Fatalf("PollForToken() error = %v, want authorization denied", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollForTokenSlowDownContinuesPolling(t *testing.T) {
|
||||
var pollCount int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count := atomic.AddInt32(&pollCount, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if count == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "slow_down"})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "access-slow",
|
||||
"refresh_token": "refresh-slow",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
auth := NewXAIAuth(nil)
|
||||
tokenData, err := auth.PollForToken(context.Background(), &DeviceCodeResponse{
|
||||
DeviceCode: "device-abc",
|
||||
UserCode: "ABCD-1234",
|
||||
ExpiresIn: 60,
|
||||
Interval: 5,
|
||||
TokenEndpoint: server.URL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PollForToken() error = %v", err)
|
||||
}
|
||||
if tokenData.AccessToken != "access-slow" {
|
||||
t.Fatalf("access token = %q, want access-slow", tokenData.AccessToken)
|
||||
}
|
||||
if got := atomic.LoadInt32(&pollCount); got != 2 {
|
||||
t.Fatalf("poll count = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTokenDataOmitsExpireWhenExpiresInZero(t *testing.T) {
|
||||
tokenData := buildTokenData("access", "refresh", "", "Bearer", 0, "user@x.ai", "sub-1")
|
||||
if tokenData.Expire != "" {
|
||||
t.Fatalf("Expire = %q, want empty", tokenData.Expire)
|
||||
}
|
||||
tokenData = buildTokenData("access", "refresh", "", "Bearer", 60, "user@x.ai", "sub-1")
|
||||
if tokenData.Expire == "" {
|
||||
t.Fatal("Expire empty, want RFC3339 timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokensPostsClientIDAndRefreshToken(t *testing.T) {
|
||||
var gotForm url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/x-www-form-urlencoded") {
|
||||
t.Fatalf("Content-Type = %q, want form", got)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm() error = %v", err)
|
||||
}
|
||||
gotForm = r.PostForm
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "new-access",
|
||||
"refresh_token": "new-refresh",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
auth := NewXAIAuth(nil)
|
||||
tokenData, err := auth.RefreshTokens(context.Background(), "old-refresh", server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshTokens() error = %v", err)
|
||||
}
|
||||
if tokenData.AccessToken != "new-access" {
|
||||
t.Fatalf("access token = %q, want new-access", tokenData.AccessToken)
|
||||
}
|
||||
if gotForm.Get("grant_type") != "refresh_token" {
|
||||
t.Fatalf("grant_type = %q, want refresh_token", gotForm.Get("grant_type"))
|
||||
}
|
||||
if gotForm.Get("client_id") != ClientID {
|
||||
t.Fatalf("client_id = %q, want %q", gotForm.Get("client_id"), ClientID)
|
||||
}
|
||||
if gotForm.Get("refresh_token") != "old-refresh" {
|
||||
t.Fatalf("refresh_token = %q, want old-refresh", gotForm.Get("refresh_token"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokens_DeduplicatesConcurrentRefresh(t *testing.T) {
|
||||
resetXAIRefreshGroupForTest()
|
||||
t.Cleanup(resetXAIRefreshGroupForTest)
|
||||
|
||||
var calls int32
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
once.Do(func() { close(started) })
|
||||
<-release
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "new-access",
|
||||
"refresh_token": "new-refresh",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
authA := NewXAIAuth(nil)
|
||||
authB := NewXAIAuth(nil)
|
||||
results := make(chan *TokenData, 2)
|
||||
errs := make(chan error, 2)
|
||||
runRefresh := func(auth *XAIAuth, launched chan<- struct{}) {
|
||||
if launched != nil {
|
||||
close(launched)
|
||||
}
|
||||
tokenData, errRefresh := auth.RefreshTokens(context.Background(), "shared-refresh-token", server.URL)
|
||||
results <- tokenData
|
||||
errs <- errRefresh
|
||||
}
|
||||
|
||||
go runRefresh(authA, nil)
|
||||
<-started
|
||||
|
||||
secondLaunched := make(chan struct{})
|
||||
go runRefresh(authB, secondLaunched)
|
||||
<-secondLaunched
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected concurrent refresh to share a single upstream call, got %d", got)
|
||||
}
|
||||
close(release)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if errRefresh := <-errs; errRefresh != nil {
|
||||
t.Fatalf("expected refresh to succeed, got %v", errRefresh)
|
||||
}
|
||||
tokenData := <-results
|
||||
if tokenData == nil || tokenData.AccessToken != "new-access" || tokenData.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("unexpected token data: %#v", tokenData)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected both refresh callers to share a single upstream call, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func fakeJWTWithEmail(email, subject string) string {
|
||||
header := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
||||
payload := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(`{"email":"` + email + `","sub":"` + subject + `"}`))
|
||||
return header + "." + payload + ".sig"
|
||||
}
|
||||
Loading…
Reference in a new issue