feat(oauth): refactor for device flow and cancelable sessions, extend test coverage

- Replaced PKCE-based OAuth for xAI with device authorization flow for enhanced compatibility.
- Introduced cancelable OAuth sessions with server-side invalidation to prevent token persistence on flow interruption.
- Updated OAuth polling and session tracking to handle device flow specifics, such as `user_code` and extended timeouts.
- Refactored TUI OAuth model to track polling generations, distinguish device flow, and handle session cancellation gracefully.
- Extended test cases for device flow scenarios, stale session handling, and cancelation logic.
- Streamlined xAI device flow management in handler logic and improved error handling.
This commit is contained in:
Luis Pater
2026-07-11 02:50:23 +08:00
parent dc162b938e
commit 6e819ab622
16 changed files with 1254 additions and 554 deletions
+101 -100
View File
@@ -2035,6 +2035,9 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
Storage: tokenStorage,
Metadata: map[string]any{"email": tokenStorage.Email},
}
if errGuard := guardOAuthSessionPendingForSave(state, "anthropic"); errGuard != nil {
return
}
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Errorf("Failed to save authentication tokens: %v", errSave)
@@ -2181,6 +2184,9 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
"account_id": tokenStorage.AccountID,
},
}
if errGuard := guardOAuthSessionPendingForSave(state, "codex"); errGuard != nil {
return
}
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
SetOAuthSessionError(state, "Failed to save authentication tokens")
@@ -2344,6 +2350,9 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
Label: label,
Metadata: metadata,
}
if errGuard := guardOAuthSessionPendingForSave(state, "antigravity"); errGuard != nil {
return
}
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Errorf("Failed to save token to file: %v", errSave)
@@ -2368,114 +2377,38 @@ func (h *Handler) RequestXAIToken(c *gin.Context) {
fmt.Println("Initializing xAI authentication...")
pkceCodes, errPKCE := xaiauth.GeneratePKCECodes()
if errPKCE != nil {
log.Errorf("Failed to generate xAI PKCE codes: %v", errPKCE)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PKCE codes"})
return
}
state, errState := misc.GenerateRandomState()
if errState != nil {
log.Errorf("Failed to generate state parameter: %v", errState)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"})
return
}
nonce, errNonce := misc.GenerateRandomState()
if errNonce != nil {
log.Errorf("Failed to generate nonce parameter: %v", errNonce)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate nonce parameter"})
return
}
state := fmt.Sprintf("xai-%d", time.Now().UnixNano())
authSvc := xaiauth.NewXAIAuth(h.cfg)
discovery, errDiscover := authSvc.Discover(ctx)
if errDiscover != nil {
log.Errorf("Failed to discover xAI OAuth endpoints: %v", errDiscover)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to discover oauth endpoints"})
deviceFlow, errStartDeviceFlow := authSvc.StartDeviceFlow(ctx)
if errStartDeviceFlow != nil {
log.Errorf("Failed to start xAI device flow: %v", errStartDeviceFlow)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start device authorization flow"})
return
}
redirectURI := fmt.Sprintf("http://%s:%d%s", xaiauth.RedirectHost, xaiauth.CallbackPort, xaiauth.RedirectPath)
authURL, errAuthURL := xaiauth.BuildAuthorizeURL(xaiauth.AuthorizeURLParams{
AuthorizationEndpoint: discovery.AuthorizationEndpoint,
RedirectURI: redirectURI,
CodeChallenge: pkceCodes.CodeChallenge,
State: state,
Nonce: nonce,
})
if errAuthURL != nil {
log.Errorf("Failed to generate xAI authorization URL: %v", errAuthURL)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
return
authURL := strings.TrimSpace(deviceFlow.VerificationURIComplete)
if authURL == "" {
authURL = strings.TrimSpace(deviceFlow.VerificationURI)
}
RegisterOAuthSession(state, "xai")
isWebUI := isWebUIRequest(c)
var forwarder *callbackForwarder
if isWebUI {
targetURL, errTarget := h.managementCallbackURL("/xai/callback")
if errTarget != nil {
log.WithError(errTarget).Error("failed to compute xai callback target")
c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
return
}
var errStart error
if forwarder, errStart = startCallbackForwarder(xaiauth.CallbackPort, "xai", targetURL); errStart != nil {
log.WithError(errStart).Error("failed to start xai callback forwarder")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
return
}
}
go func() {
if isWebUI {
defer stopCallbackForwarderInstance(xaiauth.CallbackPort, forwarder)
}
pollCtx, cancelPoll := context.WithCancel(ctx)
defer cancelPoll()
go watchOAuthSessionCancel(pollCtx, cancelPoll, state, "xai")
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-xai-%s.oauth", state))
deadline := time.Now().Add(5 * time.Minute)
var authCode string
for {
fmt.Println("Waiting for xAI authentication...")
bundle, errWaitForAuthorization := authSvc.WaitForAuthorization(pollCtx, deviceFlow)
if errWaitForAuthorization != nil {
if !IsOAuthSessionPending(state, "xai") {
return
}
if time.Now().After(deadline) {
log.Error("xai oauth flow timed out")
SetOAuthSessionError(state, "OAuth flow timed out")
return
}
if data, errReadFile := os.ReadFile(waitFile); errReadFile == nil {
var payload map[string]string
_ = json.Unmarshal(data, &payload)
_ = os.Remove(waitFile)
if errStr := strings.TrimSpace(payload["error"]); errStr != "" {
log.Errorf("xAI authentication failed: %s", errStr)
SetOAuthSessionError(state, "Authentication failed: "+errStr)
return
}
if payloadState := strings.TrimSpace(payload["state"]); payloadState != "" && payloadState != state {
log.Errorf("xAI authentication failed: state mismatch")
SetOAuthSessionError(state, "Authentication failed: state mismatch")
return
}
authCode = strings.TrimSpace(payload["code"])
if authCode == "" {
log.Error("xAI authentication failed: code not found")
SetOAuthSessionError(state, "Authentication failed: code not found")
return
}
break
}
time.Sleep(500 * time.Millisecond)
log.Errorf("xAI authentication failed: %v", errWaitForAuthorization)
SetOAuthSessionError(state, oauthSessionErrorWithCause("Authentication failed", errWaitForAuthorization))
return
}
bundle, errExchange := authSvc.ExchangeCodeForTokens(ctx, authCode, redirectURI, pkceCodes, discovery.TokenEndpoint)
if errExchange != nil {
log.Errorf("Failed to exchange xAI token: %v", errExchange)
SetOAuthSessionError(state, oauthSessionErrorWithCause("Failed to exchange authorization code for tokens", errExchange))
if !IsOAuthSessionPending(state, "xai") {
return
}
@@ -2502,7 +2435,6 @@ func (h *Handler) RequestXAIToken(c *gin.Context) {
"expired": tokenStorage.Expire,
"last_refresh": tokenStorage.LastRefresh,
"base_url": tokenStorage.BaseURL,
"redirect_uri": tokenStorage.RedirectURI,
"token_endpoint": tokenStorage.TokenEndpoint,
"auth_kind": "oauth",
}
@@ -2525,6 +2457,9 @@ func (h *Handler) RequestXAIToken(c *gin.Context) {
"base_url": tokenStorage.BaseURL,
},
}
if errGuard := guardOAuthSessionPendingForSave(state, "xai"); errGuard != nil {
return
}
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Errorf("Failed to save xAI token to file: %v", errSave)
@@ -2537,7 +2472,16 @@ func (h *Handler) RequestXAIToken(c *gin.Context) {
fmt.Println("You can now use xAI services through this CLI")
}()
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
response := gin.H{"status": "ok", "url": authURL, "state": state, "flow": "device"}
if userCode := strings.TrimSpace(deviceFlow.UserCode); userCode != "" {
response["user_code"] = userCode
}
if deviceFlow.ExpiresIn > 0 {
response["expires_in"] = deviceFlow.ExpiresIn
} else {
response["expires_in"] = int(xaiauth.MaxPollDuration / time.Second)
}
c.JSON(200, response)
}
func (h *Handler) RequestKimiToken(c *gin.Context) {
@@ -2565,13 +2509,23 @@ func (h *Handler) RequestKimiToken(c *gin.Context) {
RegisterOAuthSession(state, "kimi")
go func() {
pollCtx, cancelPoll := context.WithCancel(ctx)
defer cancelPoll()
go watchOAuthSessionCancel(pollCtx, cancelPoll, state, "kimi")
fmt.Println("Waiting for authentication...")
authBundle, errWaitForAuthorization := kimiAuth.WaitForAuthorization(ctx, deviceFlow)
authBundle, errWaitForAuthorization := kimiAuth.WaitForAuthorization(pollCtx, deviceFlow)
if errWaitForAuthorization != nil {
SetOAuthSessionError(state, "Authentication failed")
if !IsOAuthSessionPending(state, "kimi") {
return
}
SetOAuthSessionError(state, oauthSessionErrorWithCause("Authentication failed", errWaitForAuthorization))
fmt.Printf("Authentication failed: %v\n", errWaitForAuthorization)
return
}
if !IsOAuthSessionPending(state, "kimi") {
return
}
// Create token storage
tokenStorage := kimiAuth.CreateTokenStorage(authBundle)
@@ -2601,6 +2555,9 @@ func (h *Handler) RequestKimiToken(c *gin.Context) {
Storage: tokenStorage,
Metadata: metadata,
}
if errGuard := guardOAuthSessionPendingForSave(state, "kimi"); errGuard != nil {
return
}
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Errorf("Failed to save authentication tokens: %v", errSave)
@@ -2613,7 +2570,51 @@ func (h *Handler) RequestKimiToken(c *gin.Context) {
CompleteOAuthSession(state)
}()
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
response := gin.H{"status": "ok", "url": authURL, "state": state, "flow": "device"}
if userCode := strings.TrimSpace(deviceFlow.UserCode); userCode != "" {
response["user_code"] = userCode
}
if deviceFlow.ExpiresIn > 0 {
response["expires_in"] = deviceFlow.ExpiresIn
}
c.JSON(200, response)
}
// watchOAuthSessionCancel cancels pollCtx once the OAuth session is no longer pending.
func watchOAuthSessionCancel(pollCtx context.Context, cancel context.CancelFunc, state, provider string) {
if cancel == nil {
return
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-pollCtx.Done():
return
case <-ticker.C:
if !IsOAuthSessionPending(state, provider) {
cancel()
return
}
}
}
}
// CancelAuthSession cancels a pending OAuth session identified by state.
// Protected by management auth. Safe for both callback and device-code flows:
// waiters check IsOAuthSessionPending and exit without saving credentials.
func (h *Handler) CancelAuthSession(c *gin.Context) {
state := strings.TrimSpace(c.Query("state"))
if state == "" {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "missing state"})
return
}
if err := ValidateOAuthState(state); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid state"})
return
}
cancelled := CancelOAuthSession(state)
c.JSON(http.StatusOK, gin.H{"status": "ok", "cancelled": cancelled})
}
func (h *Handler) GetAuthStatus(c *gin.Context) {
@@ -117,7 +117,8 @@ func TestGetOAuthCallbackDoesNotAliasPluginProvider(t *testing.T) {
}
func TestWriteOAuthCallbackFileForPendingSessionCreatesMissingAuthDirForCallbackProviders(t *testing.T) {
providers := []string{"anthropic", "codex", "gemini", "antigravity", "xai"}
// xAI uses device-code flow and no longer writes callback files.
providers := []string{"anthropic", "codex", "gemini", "antigravity"}
for _, provider := range providers {
t.Run(provider, func(t *testing.T) {
authDir := filepath.Join(t.TempDir(), "missing-auth")
@@ -12,7 +12,8 @@ import (
)
const (
oauthSessionTTL = 10 * time.Minute
// oauthSessionTTL must cover device-code flows (xAI ~30m, Kimi ~15m).
oauthSessionTTL = 30 * time.Minute
oauthCompletedSessionTTL = time.Minute
maxOAuthStateLength = 128
)
@@ -226,6 +227,27 @@ func (s *oauthSessionStore) IsPending(state, provider string) bool {
return strings.EqualFold(session.Provider, provider)
}
// Cancel removes a pending OAuth session so background waiters exit without saving credentials.
// Returns true when a pending session was cancelled.
func (s *oauthSessionStore) Cancel(state string) bool {
state = strings.TrimSpace(state)
if state == "" {
return false
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
s.purgeExpiredLocked(now)
session, ok := s.sessions[state]
if !ok || session.Completed || session.Status != "" {
return false
}
delete(s.sessions, state)
return true
}
func cloneOAuthSessionMetadata(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
@@ -277,6 +299,23 @@ func IsOAuthSessionPending(state, provider string) bool {
return oauthSessions.IsPending(state, provider)
}
// guardOAuthSessionPendingForSave returns errOAuthSessionNotPending when the session
// is no longer pending (cancelled, completed, errored, or expired).
// Call immediately before persisting credentials so a cancel that races with token
// exchange or metadata fetch cannot save credentials for a cancelled flow.
func guardOAuthSessionPendingForSave(state, provider string) error {
if IsOAuthSessionPending(state, provider) {
return nil
}
return errOAuthSessionNotPending
}
// CancelOAuthSession cancels a pending OAuth session by state.
// Background callback and device-code waiters observe IsOAuthSessionPending as false and exit without saving credentials.
func CancelOAuthSession(state string) bool {
return oauthSessions.Cancel(state)
}
func oauthSessionErrorWithCause(message string, cause error) string {
message = strings.TrimSpace(message)
if message == "" {
@@ -2,6 +2,7 @@ package management
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -156,6 +157,183 @@ func performOAuthStatusRequest(t *testing.T, router http.Handler, state string)
return response
}
func TestOAuthSessionStoreCancelRemovesPendingSession(t *testing.T) {
store := newOAuthSessionStore(time.Minute)
store.Register("pending-state", "xai")
if !store.Cancel("pending-state") {
t.Fatal("Cancel() = false, want true for pending session")
}
if store.IsPending("pending-state", "xai") {
t.Fatal("cancelled session remained pending")
}
if _, ok := store.Get("pending-state"); ok {
t.Fatal("cancelled session still present in store")
}
if store.Cancel("pending-state") {
t.Fatal("second Cancel() = true, want false")
}
}
func TestOAuthSessionStoreCancelIgnoresCompletedAndUnknown(t *testing.T) {
store := newOAuthSessionStore(time.Minute)
store.Register("completed-state", "codex")
store.Complete("completed-state")
if store.Cancel("completed-state") {
t.Fatal("Cancel() completed session = true, want false")
}
if _, ok := store.Get("completed-state"); !ok {
t.Fatal("completed tombstone was removed by Cancel")
}
if store.Cancel("missing-state") {
t.Fatal("Cancel() unknown session = true, want false")
}
}
func TestOAuthSessionStoreCancelIgnoresErrorSession(t *testing.T) {
store := newOAuthSessionStore(time.Minute)
store.Register("error-state", "kimi")
store.SetError("error-state", "Authentication failed")
if store.IsPending("error-state", "kimi") {
t.Fatal("error session should not be pending")
}
if store.Cancel("error-state") {
t.Fatal("Cancel() error session = true, want false")
}
}
func TestCancelOAuthSessionAndCallbackRejectAfterCancel(t *testing.T) {
store := newOAuthSessionStore(time.Minute)
replaceOAuthSessionStoreForTest(t, store)
store.Register("callback-state", "anthropic")
if !CancelOAuthSession("callback-state") {
t.Fatal("CancelOAuthSession() = false, want true")
}
if IsOAuthSessionPending("callback-state", "anthropic") {
t.Fatal("session still pending after cancel")
}
_, errWrite := WriteOAuthCallbackFileForPendingSession(t.TempDir(), "anthropic", "callback-state", "code", "")
if errWrite == nil {
t.Fatal("expected callback write to fail after cancel")
}
if !errors.Is(errWrite, errOAuthSessionNotPending) {
t.Fatalf("callback write error = %v, want %v", errWrite, errOAuthSessionNotPending)
}
}
func TestGuardOAuthSessionPendingForSave(t *testing.T) {
store := newOAuthSessionStore(time.Minute)
replaceOAuthSessionStoreForTest(t, store)
providers := []string{"anthropic", "codex", "antigravity", "xai", "kimi"}
for _, provider := range providers {
state := provider + "-save-guard"
store.Register(state, provider)
if errGuard := guardOAuthSessionPendingForSave(state, provider); errGuard != nil {
t.Fatalf("%s pending guard error = %v, want nil", provider, errGuard)
}
if !CancelOAuthSession(state) {
t.Fatalf("%s CancelOAuthSession() = false, want true", provider)
}
if errGuard := guardOAuthSessionPendingForSave(state, provider); !errors.Is(errGuard, errOAuthSessionNotPending) {
t.Fatalf("%s after cancel guard error = %v, want %v", provider, errGuard, errOAuthSessionNotPending)
}
}
// Completed and errored sessions must also refuse save.
store.Register("completed-save", "codex")
store.Complete("completed-save")
if errGuard := guardOAuthSessionPendingForSave("completed-save", "codex"); !errors.Is(errGuard, errOAuthSessionNotPending) {
t.Fatalf("completed guard error = %v, want %v", errGuard, errOAuthSessionNotPending)
}
store.Register("error-save", "anthropic")
store.SetError("error-save", "Authentication failed")
if errGuard := guardOAuthSessionPendingForSave("error-save", "anthropic"); !errors.Is(errGuard, errOAuthSessionNotPending) {
t.Fatalf("error guard error = %v, want %v", errGuard, errOAuthSessionNotPending)
}
}
func TestCancelAuthSessionHandler(t *testing.T) {
store := newOAuthSessionStore(time.Minute)
replaceOAuthSessionStoreForTest(t, store)
store.Register("device-state", "xai")
handler := &Handler{}
router := gin.New()
router.DELETE("/oauth-session", handler.CancelAuthSession)
missing := performOAuthCancelRequest(t, router, "")
if missing.status != http.StatusBadRequest {
t.Fatalf("missing state status = %d, want %d", missing.status, http.StatusBadRequest)
}
invalid := performOAuthCancelRequest(t, router, "bad/state")
if invalid.status != http.StatusBadRequest {
t.Fatalf("invalid state status = %d, want %d", invalid.status, http.StatusBadRequest)
}
cancelled := performOAuthCancelRequest(t, router, "device-state")
if cancelled.status != http.StatusOK || !cancelled.cancelled || cancelled.bodyStatus != "ok" {
t.Fatalf("cancel pending response = %#v, want ok/cancelled", cancelled)
}
if IsOAuthSessionPending("device-state", "xai") {
t.Fatal("device session still pending after cancel API")
}
repeat := performOAuthCancelRequest(t, router, "device-state")
if repeat.status != http.StatusOK || repeat.cancelled {
t.Fatalf("repeat cancel response = %#v, want ok with cancelled=false", repeat)
}
// Status after cancel should not report success.
statusRouter := gin.New()
statusRouter.GET("/status", handler.GetAuthStatus)
unknown := performOAuthStatusRequest(t, statusRouter, "device-state")
if unknown.Status != "error" || unknown.Error != "unknown or expired state" {
t.Fatalf("status after cancel = %#v, want unknown/expired error", unknown)
}
}
type oauthCancelResponse struct {
status int
bodyStatus string
cancelled bool
}
func performOAuthCancelRequest(t *testing.T, router http.Handler, state string) oauthCancelResponse {
t.Helper()
path := "/oauth-session"
if state != "" {
path += "?state=" + state
}
req := httptest.NewRequest(http.MethodDelete, path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var body struct {
Status string `json:"status"`
Cancelled bool `json:"cancelled"`
Error string `json:"error"`
}
if w.Body.Len() > 0 {
if errDecode := json.Unmarshal(w.Body.Bytes(), &body); errDecode != nil {
t.Fatalf("decode cancel response: %v body=%s", errDecode, w.Body.String())
}
}
return oauthCancelResponse{
status: w.Code,
bodyStatus: body.Status,
cancelled: body.Cancelled,
}
}
func replaceOAuthSessionStoreForTest(t *testing.T, store *oauthSessionStore) {
t.Helper()
original := oauthSessions
+1 -14
View File
@@ -618,20 +618,6 @@ func (s *Server) setupRoutes() {
c.String(http.StatusOK, oauthCallbackSuccessHTML)
})
s.engine.GET("/xai/callback", func(c *gin.Context) {
code := c.Query("code")
state := c.Query("state")
errStr := c.Query("error")
if errStr == "" {
errStr = c.Query("error_description")
}
if state != "" {
_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "xai", state, code, errStr)
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, oauthCallbackSuccessHTML)
})
// Management routes are registered lazily by registerManagementRoutes when a secret is configured.
}
@@ -827,6 +813,7 @@ func (s *Server) registerManagementRoutes() {
mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken)
mgmt.GET("/xai-auth-url", s.mgmt.RequestXAIToken)
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
mgmt.DELETE("/oauth-session", s.mgmt.CancelAuthSession)
}
}
-20
View File
@@ -1,20 +0,0 @@
package xai
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
)
// GeneratePKCECodes creates a verifier/challenge pair for the OAuth flow.
func GeneratePKCECodes() (*PKCECodes, error) {
bytes := make([]byte, 96)
if _, err := rand.Read(bytes); err != nil {
return nil, fmt.Errorf("xai pkce: generate verifier: %w", err)
}
verifier := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes)
hash := sha256.Sum256([]byte(verifier))
challenge := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
return &PKCECodes{CodeVerifier: verifier, CodeChallenge: challenge}, nil
}
+21 -23
View File
@@ -18,12 +18,14 @@ const (
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"
// RedirectHost is the loopback host used by xAI OAuth.
RedirectHost = "127.0.0.1"
// CallbackPort is the preferred loopback callback port.
CallbackPort = 56121
// RedirectPath is the loopback callback path registered by the xAI client.
RedirectPath = "/callback"
// 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
@@ -33,25 +35,21 @@ func RefreshLead() time.Duration {
return refreshLead
}
// PKCECodes holds the PKCE verifier/challenge pair.
type PKCECodes struct {
CodeVerifier string
CodeChallenge string
}
// AuthorizeURLParams contains the values used to build the xAI OAuth URL.
type AuthorizeURLParams struct {
AuthorizationEndpoint string
RedirectURI string
CodeChallenge string
State string
Nonce string
}
// Discovery contains OAuth endpoints resolved from xAI OIDC discovery.
type Discovery struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
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.
+228 -72
View File
@@ -17,7 +17,7 @@ import (
"golang.org/x/sync/singleflight"
)
// XAIAuth performs xAI OAuth discovery, token exchange, and refresh.
// XAIAuth performs xAI OAuth discovery, device-code login, and refresh.
type XAIAuth struct {
httpClient *http.Client
}
@@ -40,7 +40,7 @@ func NewXAIAuthWithProxyURL(cfg *config.Config, proxyURL string) *XAIAuth {
}
}
sdkCfg.ProxyURL = effectiveProxyURL
return &XAIAuth{httpClient: util.SetProxy(&sdkCfg, &http.Client{})}
return &XAIAuth{httpClient: util.SetProxy(&sdkCfg, &http.Client{Timeout: httpClientTimeout})}
}
// ValidateOAuthEndpoint validates an endpoint returned by xAI discovery.
@@ -63,39 +63,6 @@ func ValidateOAuthEndpoint(rawURL string, field string) (string, error) {
return rawURL, nil
}
// BuildAuthorizeURL builds the browser URL for xAI OAuth.
func BuildAuthorizeURL(params AuthorizeURLParams) (string, error) {
endpoint, err := ValidateOAuthEndpoint(params.AuthorizationEndpoint, "authorization_endpoint")
if err != nil {
return "", err
}
if strings.TrimSpace(params.RedirectURI) == "" {
return "", fmt.Errorf("xai authorize URL: redirect URI is required")
}
if strings.TrimSpace(params.CodeChallenge) == "" {
return "", fmt.Errorf("xai authorize URL: code challenge is required")
}
if strings.TrimSpace(params.State) == "" {
return "", fmt.Errorf("xai authorize URL: state is required")
}
if strings.TrimSpace(params.Nonce) == "" {
return "", fmt.Errorf("xai authorize URL: nonce is required")
}
values := url.Values{
"response_type": {"code"},
"client_id": {ClientID},
"redirect_uri": {strings.TrimSpace(params.RedirectURI)},
"scope": {Scope},
"code_challenge": {strings.TrimSpace(params.CodeChallenge)},
"code_challenge_method": {"S256"},
"state": {strings.TrimSpace(params.State)},
"nonce": {strings.TrimSpace(params.Nonce)},
"plan": {"generic"},
"referrer": {"cli-proxy-api"},
}
return endpoint + "?" + values.Encode(), nil
}
// Discover resolves xAI OAuth endpoints through OIDC discovery.
func (a *XAIAuth) Discover(ctx context.Context) (*Discovery, error) {
if ctx == nil {
@@ -123,13 +90,13 @@ func (a *XAIAuth) Discover(ctx context.Context) (*Discovery, error) {
return nil, fmt.Errorf("xai discovery failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var payload struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
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)
}
authorizationEndpoint, err := ValidateOAuthEndpoint(payload.AuthorizationEndpoint, "authorization_endpoint")
deviceAuthorizationEndpoint, err := ValidateOAuthEndpoint(payload.DeviceAuthorizationEndpoint, "device_authorization_endpoint")
if err != nil {
return nil, err
}
@@ -137,45 +104,227 @@ func (a *XAIAuth) Discover(ctx context.Context) (*Discovery, error) {
if err != nil {
return nil, err
}
return &Discovery{AuthorizationEndpoint: authorizationEndpoint, TokenEndpoint: tokenEndpoint}, nil
return &Discovery{
DeviceAuthorizationEndpoint: deviceAuthorizationEndpoint,
TokenEndpoint: tokenEndpoint,
}, nil
}
// ExchangeCodeForTokens exchanges an authorization code for xAI OAuth tokens.
func (a *XAIAuth) ExchangeCodeForTokens(ctx context.Context, code, redirectURI string, pkceCodes *PKCECodes, tokenEndpoint string) (*AuthBundle, error) {
if pkceCodes == nil {
return nil, fmt.Errorf("xai token exchange: PKCE codes are required")
// 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
}
if strings.TrimSpace(code) == "" {
return nil, fmt.Errorf("xai token exchange: authorization code is required")
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()
}
if strings.TrimSpace(redirectURI) == "" {
return nil, fmt.Errorf("xai token exchange: redirect URI is required")
deviceAuthorizationEndpoint = strings.TrimSpace(deviceAuthorizationEndpoint)
if deviceAuthorizationEndpoint == "" {
return nil, fmt.Errorf("xai device code: device authorization endpoint is required")
}
if strings.TrimSpace(tokenEndpoint) == "" {
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": {"authorization_code"},
"code": {strings.TrimSpace(code)},
"redirect_uri": {strings.TrimSpace(redirectURI)},
"client_id": {ClientID},
"code_verifier": {pkceCodes.CodeVerifier},
"grant_type": {DeviceCodeGrantType},
"device_code": {strings.TrimSpace(deviceCode)},
"client_id": {ClientID},
}
tokenData, err := a.postTokenForm(ctx, tokenEndpoint, form)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(tokenEndpoint), strings.NewReader(form.Encode()))
if err != nil {
return nil, err
return nil, fmt.Errorf("xai device token: create request: %w", err), interval, false
}
return &AuthBundle{
TokenData: *tokenData,
LastRefresh: time.Now().UTC().Format(time.RFC3339),
BaseURL: DefaultAPIBaseURL,
RedirectURI: strings.TrimSpace(redirectURI),
TokenEndpoint: strings.TrimSpace(tokenEndpoint),
}, nil
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.
@@ -258,16 +407,7 @@ func (a *XAIAuth) postTokenForm(ctx context.Context, tokenEndpoint string, form
return nil, fmt.Errorf("xai token response missing access_token")
}
email, subject := parseJWTIdentity(payload.IDToken)
return &TokenData{
AccessToken: strings.TrimSpace(payload.AccessToken),
RefreshToken: strings.TrimSpace(payload.RefreshToken),
IDToken: strings.TrimSpace(payload.IDToken),
TokenType: strings.TrimSpace(payload.TokenType),
ExpiresIn: payload.ExpiresIn,
Expire: time.Now().Add(time.Duration(payload.ExpiresIn) * time.Second).UTC().Format(time.RFC3339),
Email: email,
Subject: subject,
}, nil
return buildTokenData(payload.AccessToken, payload.RefreshToken, payload.IDToken, payload.TokenType, payload.ExpiresIn, email, subject), nil
}
// CreateTokenStorage converts an auth bundle into persistable storage.
@@ -293,6 +433,22 @@ func (a *XAIAuth) CreateTokenStorage(bundle *AuthBundle) *TokenStorage {
}
}
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 {
+193 -42
View File
@@ -2,6 +2,7 @@ package xai
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -19,51 +20,11 @@ func resetXAIRefreshGroupForTest() {
xaiRefreshGroup = singleflight.Group{}
}
func TestBuildAuthorizeURLIncludesXAIRequiredParameters(t *testing.T) {
authURL, err := BuildAuthorizeURL(AuthorizeURLParams{
AuthorizationEndpoint: "https://auth.x.ai/oauth/authorize",
RedirectURI: "http://127.0.0.1:56121/callback",
CodeChallenge: "challenge",
State: "state-123",
Nonce: "nonce-123",
})
if err != nil {
t.Fatalf("BuildAuthorizeURL() error = %v", err)
}
parsed, errParse := url.Parse(authURL)
if errParse != nil {
t.Fatalf("parse authorize URL: %v", errParse)
}
if parsed.Scheme != "https" || parsed.Host != "auth.x.ai" || parsed.Path != "/oauth/authorize" {
t.Fatalf("authorize URL endpoint = %s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
}
query := parsed.Query()
want := map[string]string{
"response_type": "code",
"client_id": ClientID,
"redirect_uri": "http://127.0.0.1:56121/callback",
"scope": Scope,
"code_challenge": "challenge",
"code_challenge_method": "S256",
"state": "state-123",
"nonce": "nonce-123",
"plan": "generic",
"referrer": "cli-proxy-api",
}
for key, value := range want {
if got := query.Get(key); got != value {
t.Fatalf("%s = %q, want %q", key, got, value)
}
}
}
func TestValidateOAuthEndpointRejectsNonXAIOrigin(t *testing.T) {
if _, err := ValidateOAuthEndpoint("https://auth.x.ai/oauth/token", "token_endpoint"); err != nil {
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/oauth/token", "token_endpoint"); err == nil {
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 {
@@ -71,6 +32,190 @@ func TestValidateOAuthEndpointRejectsNonXAIOrigin(t *testing.T) {
}
}
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("[email protected]", "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 != "[email protected]" {
t.Fatalf("email = %q, want [email protected]", 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, "[email protected]", "sub-1")
if tokenData.Expire != "" {
t.Fatalf("Expire = %q, want empty", tokenData.Expire)
}
tokenData = buildTokenData("access", "refresh", "", "Bearer", 60, "[email protected]", "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) {
@@ -174,3 +319,9 @@ func TestRefreshTokens_DeduplicatesConcurrentRefresh(t *testing.T) {
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"
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
log "github.com/sirupsen/logrus"
)
// DoXAILogin triggers the OAuth flow for the xAI provider and saves tokens.
// DoXAILogin triggers the OAuth device-code flow for the xAI provider and saves tokens.
func DoXAILogin(cfg *config.Config, options *LoginOptions) {
if options == nil {
options = &LoginOptions{}
+19
View File
@@ -371,6 +371,25 @@ func (c *Client) GetAuthStatus(state string) (string, string, error) {
return status, errMsg, nil
}
// CancelAuthSession cancels a pending OAuth session on the management server.
func (c *Client) CancelAuthSession(state string) error {
state = strings.TrimSpace(state)
if state == "" {
return nil
}
query := url.Values{}
query.Set("state", state)
path := "/v0/management/oauth-session?" + query.Encode()
_, code, err := c.doRequest("DELETE", path, nil)
if err != nil {
return err
}
if code >= 400 {
return fmt.Errorf("HTTP %d", code)
}
return nil
}
// ----- Config field update methods -----
// PutBoolField updates a boolean config field.
+42 -34
View File
@@ -163,23 +163,27 @@ var zhStrings = map[string]string{
"enter_save_esc": " Enter: 保存 • Esc: 取消",
// ── OAuth ──
"oauth_title": "🔐 OAuth 登录",
"oauth_select": " 选择提供商并按 [Enter] 开始 OAuth 登录:",
"oauth_help": " [↑↓/jk] 导航 • [Enter] 登录 • [Esc] 清除状态",
"oauth_initiating": "⏳ 正在初始化 %s 登录...",
"oauth_success": "认证成功! 请刷新 Auth Files 标签查看新凭证。",
"oauth_completed": "认证流程已完成。",
"oauth_failed": "认证失败",
"oauth_timeout": "OAuth 流程超时 (5 分钟)",
"oauth_press_esc": " 按 [Esc] 取消",
"oauth_auth_url": " 授权链接:",
"oauth_remote_hint": " 远程浏览器模式:在浏览器中打开上述链接完成授权后,将回调 URL 粘贴到下方。",
"oauth_callback_url": " 回调 URL:",
"oauth_press_c": " 按 [c] 输入回调 URL • [Esc] 返回",
"oauth_submitting": "⏳ 提交回调中...",
"oauth_submit_ok": "✓ 回调已提交,等待处理...",
"oauth_submit_fail": "✗ 提交回调失败",
"oauth_waiting": " 等待认证中...",
"oauth_title": "🔐 OAuth 登录",
"oauth_select": " 选择提供商并按 [Enter] 开始 OAuth 登录:",
"oauth_help": " [↑↓/jk] 导航 • [Enter] 登录 • [Esc] 清除状态",
"oauth_initiating": "⏳ 正在初始化 %s 登录...",
"oauth_success": "认证成功! 请刷新 Auth Files 标签查看新凭证。",
"oauth_completed": "认证流程已完成。",
"oauth_failed": "认证失败",
"oauth_timeout": "OAuth 流程超时",
"oauth_status_error": "无法查询 OAuth 状态",
"oauth_press_esc": " 按 [Esc] 取消",
"oauth_auth_url": " 授权链接:",
"oauth_remote_hint": " 远程浏览器模式:在浏览器中打开上述链接完成授权后,将回调 URL 粘贴到下方。",
"oauth_callback_url": " 回调 URL:",
"oauth_press_c": " 按 [c] 输入回调 URL • [Esc] 返回",
"oauth_submitting": "⏳ 提交回调中...",
"oauth_submit_ok": "✓ 回调已提交,等待处理...",
"oauth_submit_fail": "✗ 提交回调失败",
"oauth_waiting": " 等待认证中...",
"oauth_user_code": " 用户码:",
"oauth_device_hint": " 设备码登录:在浏览器打开上述链接并确认授权,无需粘贴回调 URL。",
"oauth_device_expires": " 设备码将在 %d 秒后过期。",
// ── Usage ──
"usage_title": "📈 使用统计",
@@ -314,23 +318,27 @@ var enStrings = map[string]string{
"enter_save_esc": " Enter: Save • Esc: Cancel",
// ── OAuth ──
"oauth_title": "🔐 OAuth Login",
"oauth_select": " Select a provider and press [Enter] to start OAuth login:",
"oauth_help": " [↑↓/jk] Navigate • [Enter] Login • [Esc] Clear status",
"oauth_initiating": "⏳ Initiating %s login...",
"oauth_success": "Authentication successful! Refresh Auth Files tab to see the new credential.",
"oauth_completed": "Authentication flow completed.",
"oauth_failed": "Authentication failed",
"oauth_timeout": "OAuth flow timed out (5 minutes)",
"oauth_press_esc": " Press [Esc] to cancel",
"oauth_auth_url": " Authorization URL:",
"oauth_remote_hint": " Remote browser mode: Open the URL above in browser, paste the callback URL below after authorization.",
"oauth_callback_url": " Callback URL:",
"oauth_press_c": " Press [c] to enter callback URL • [Esc] to go back",
"oauth_submitting": "⏳ Submitting callback...",
"oauth_submit_ok": "✓ Callback submitted, waiting...",
"oauth_submit_fail": "✗ Callback submission failed",
"oauth_waiting": " Waiting for authentication...",
"oauth_title": "🔐 OAuth Login",
"oauth_select": " Select a provider and press [Enter] to start OAuth login:",
"oauth_help": " [↑↓/jk] Navigate • [Enter] Login • [Esc] Clear status",
"oauth_initiating": "⏳ Initiating %s login...",
"oauth_success": "Authentication successful! Refresh Auth Files tab to see the new credential.",
"oauth_completed": "Authentication flow completed.",
"oauth_failed": "Authentication failed",
"oauth_timeout": "OAuth flow timed out",
"oauth_status_error": "Failed to query OAuth status",
"oauth_press_esc": " Press [Esc] to cancel",
"oauth_auth_url": " Authorization URL:",
"oauth_remote_hint": " Remote browser mode: Open the URL above in browser, paste the callback URL below after authorization.",
"oauth_callback_url": " Callback URL:",
"oauth_press_c": " Press [c] to enter callback URL • [Esc] to go back",
"oauth_submitting": "⏳ Submitting callback...",
"oauth_submit_ok": "✓ Callback submitted, waiting...",
"oauth_submit_fail": "✗ Callback submission failed",
"oauth_waiting": " Waiting for authentication...",
"oauth_user_code": " User code:",
"oauth_device_hint": " Device-code login: open the URL above and approve access. No callback URL paste is required.",
"oauth_device_expires": " Device code expires in %d seconds.",
// ── Usage ──
"usage_title": "📈 Usage Statistics",
+221 -47
View File
@@ -13,17 +13,18 @@ import (
// oauthProvider represents an OAuth provider option.
type oauthProvider struct {
name string
apiPath string // management API path
emoji string
name string
apiPath string // management API path
emoji string
deviceFlow bool // true for RFC 8628 device-code providers
}
var oauthProviders = []oauthProvider{
{"Claude (Anthropic)", "anthropic-auth-url", "🟧"},
{"Codex (OpenAI)", "codex-auth-url", "🟩"},
{"Antigravity", "antigravity-auth-url", "🟪"},
{"Kimi", "kimi-auth-url", "🟫"},
{"xAI", "xai-auth-url", "⬛"},
{"Claude (Anthropic)", "anthropic-auth-url", "🟧", false},
{"Codex (OpenAI)", "codex-auth-url", "🟩", false},
{"Antigravity", "antigravity-auth-url", "🟪", false},
{"Kimi", "kimi-auth-url", "🟫", true},
{"xAI", "xai-auth-url", "⬛", true},
}
// oauthTabModel handles OAuth login flows.
@@ -38,12 +39,18 @@ type oauthTabModel struct {
height int
ready bool
// Remote browser mode
// Remote browser / device-code mode
authURL string // auth URL to display
authState string // OAuth state parameter
providerName string // current provider name
userCode string // device-code user_code (optional)
deviceFlow bool // true when waiting on device authorization
expiresIn int // device-code / poll timeout in seconds
callbackInput textinput.Model
inputActive bool // true when user is typing callback URL
// pollGeneration invalidates in-flight start/poll commands after cancel or restart.
pollGeneration int
}
type oauthState int
@@ -51,23 +58,36 @@ type oauthState int
const (
oauthIdle oauthState = iota
oauthPending
oauthRemote // remote browser mode: waiting for manual callback
oauthRemote // remote browser mode: waiting for manual callback or device auth
oauthSuccess
oauthError
)
const (
defaultOAuthPollTimeout = 5 * time.Minute
deviceOAuthPollTimeout = 30 * time.Minute
maxOAuthStatusPollErrors = 5
oauthStatusPollInterval = 2 * time.Second
)
// Messages
type oauthStartMsg struct {
url string
state string
providerName string
userCode string
deviceFlow bool
expiresIn int
generation int
err error
}
type oauthPollMsg struct {
done bool
message string
err error
state string
generation int
done bool
message string
err error
}
type oauthCallbackSubmitMsg struct {
@@ -95,6 +115,13 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
m.viewport.SetContent(m.renderContent())
return m, nil
case oauthStartMsg:
if !shouldAcceptOAuthStart(msg, m.pollGeneration) {
// Stale start after Esc/restart: cancel server session so credentials are not saved.
if msg.err == nil && strings.TrimSpace(msg.state) != "" {
return m, m.cancelOAuthSession(msg.state)
}
return m, nil
}
if msg.err != nil {
m.state = oauthError
m.err = msg.err
@@ -105,16 +132,27 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
m.authURL = msg.url
m.authState = msg.state
m.providerName = msg.providerName
m.userCode = msg.userCode
m.deviceFlow = msg.deviceFlow
m.expiresIn = msg.expiresIn
m.state = oauthRemote
m.callbackInput.SetValue("")
m.message = ""
if m.deviceFlow {
m.inputActive = false
m.callbackInput.Blur()
m.viewport.SetContent(m.renderContent())
return m, m.pollOAuthStatus(msg.state, msg.expiresIn, true, msg.generation)
}
m.callbackInput.Focus()
m.inputActive = true
m.message = ""
m.viewport.SetContent(m.renderContent())
// Also start polling in the background
return m, tea.Batch(textinput.Blink, m.pollOAuthStatus(msg.state))
return m, tea.Batch(textinput.Blink, m.pollOAuthStatus(msg.state, msg.expiresIn, false, msg.generation))
case oauthPollMsg:
if !shouldAcceptOAuthPoll(msg, m.authState, m.pollGeneration, m.state) {
return m, nil
}
if msg.err != nil {
m.state = oauthError
m.err = msg.err
@@ -142,8 +180,8 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
return m, nil
case tea.KeyMsg:
// ---- Input active: typing callback URL ----
if m.inputActive {
// ---- Input active: typing callback URL (web flow only) ----
if m.inputActive && !m.deviceFlow {
switch msg.String() {
case "enter":
callbackURL := m.callbackInput.Value()
@@ -156,10 +194,8 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
m.viewport.SetContent(m.renderContent())
return m, m.submitCallback(callbackURL)
case "esc":
m.inputActive = false
m.callbackInput.Blur()
m.viewport.SetContent(m.renderContent())
return m, nil
// Cancel the remote OAuth session even while the callback input is focused.
return m, m.cancelRemoteOAuth()
default:
var cmd tea.Cmd
m.callbackInput, cmd = m.callbackInput.Update(msg)
@@ -172,18 +208,16 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
if m.state == oauthRemote {
switch msg.String() {
case "c", "C":
if m.deviceFlow {
return m, nil
}
// Re-activate input
m.inputActive = true
m.callbackInput.Focus()
m.viewport.SetContent(m.renderContent())
return m, textinput.Blink
case "esc":
m.state = oauthIdle
m.message = ""
m.authURL = ""
m.authState = ""
m.viewport.SetContent(m.renderContent())
return m, nil
return m, m.cancelRemoteOAuth()
}
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
@@ -193,6 +227,7 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
// ---- Pending (auto polling) ----
if m.state == oauthPending {
if msg.String() == "esc" {
m.pollGeneration++
m.state = oauthIdle
m.message = ""
m.viewport.SetContent(m.renderContent())
@@ -217,10 +252,11 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
case "enter":
if m.cursor >= 0 && m.cursor < len(oauthProviders) {
provider := oauthProviders[m.cursor]
m.pollGeneration++
m.state = oauthPending
m.message = warningStyle.Render(fmt.Sprintf(T("oauth_initiating"), provider.name))
m.viewport.SetContent(m.renderContent())
return m, m.startOAuth(provider)
return m, m.startOAuth(provider, m.pollGeneration)
}
return m, nil
case "esc":
@@ -241,24 +277,66 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
return m, cmd
}
func (m oauthTabModel) startOAuth(provider oauthProvider) tea.Cmd {
func (m oauthTabModel) startOAuth(provider oauthProvider, generation int) tea.Cmd {
return func() tea.Msg {
// Call the auth URL endpoint with is_webui=true
data, err := m.client.getJSON("/v0/management/" + provider.apiPath + "?is_webui=true")
if err != nil {
return oauthStartMsg{err: fmt.Errorf("failed to start %s login: %w", provider.name, err)}
return oauthStartMsg{generation: generation, err: fmt.Errorf("failed to start %s login: %w", provider.name, err)}
}
authURL := getString(data, "url")
state := getString(data, "state")
if authURL == "" {
return oauthStartMsg{err: fmt.Errorf("no auth URL returned for %s", provider.name)}
return oauthStartMsg{generation: generation, err: fmt.Errorf("no auth URL returned for %s", provider.name)}
}
userCode := getString(data, "user_code")
flow := strings.ToLower(strings.TrimSpace(getString(data, "flow")))
expiresIn := int(getFloat(data, "expires_in"))
deviceFlow := provider.deviceFlow || flow == "device" || userCode != ""
// Try to open browser (best effort)
_ = openBrowser(authURL)
return oauthStartMsg{url: authURL, state: state, providerName: provider.name}
return oauthStartMsg{
url: authURL,
state: state,
providerName: provider.name,
userCode: userCode,
deviceFlow: deviceFlow,
expiresIn: expiresIn,
generation: generation,
}
}
}
// cancelRemoteOAuth clears local remote/device UI state and cancels the server session.
func (m *oauthTabModel) cancelRemoteOAuth() tea.Cmd {
state := m.authState
m.pollGeneration++
m.state = oauthIdle
m.message = ""
m.authURL = ""
m.authState = ""
m.userCode = ""
m.deviceFlow = false
m.expiresIn = 0
m.inputActive = false
m.callbackInput.Blur()
m.callbackInput.SetValue("")
m.viewport.SetContent(m.renderContent())
return m.cancelOAuthSession(state)
}
func (m oauthTabModel) cancelOAuthSession(state string) tea.Cmd {
state = strings.TrimSpace(state)
if state == "" || m.client == nil {
return nil
}
return func() tea.Msg {
_ = m.client.CancelAuthSession(state)
return nil
}
}
@@ -298,45 +376,96 @@ func (m oauthTabModel) submitCallback(callbackURL string) tea.Cmd {
}
}
func (m oauthTabModel) pollOAuthStatus(state string) tea.Cmd {
func (m oauthTabModel) pollOAuthStatus(state string, expiresIn int, deviceFlow bool, generation int) tea.Cmd {
return func() tea.Msg {
// Poll session status for up to 5 minutes
deadline := time.Now().Add(5 * time.Minute)
timeout := defaultOAuthPollTimeout
if expiresIn > 0 {
timeout = time.Duration(expiresIn) * time.Second
} else if deviceFlow {
timeout = deviceOAuthPollTimeout
}
deadline := time.Now().Add(timeout)
consecutiveErrors := 0
for {
if time.Now().After(deadline) {
return oauthPollMsg{done: false, err: fmt.Errorf("%s", T("oauth_timeout"))}
return oauthPollMsg{
state: state,
generation: generation,
done: false,
err: fmt.Errorf("%s", T("oauth_timeout")),
}
}
time.Sleep(2 * time.Second)
time.Sleep(oauthStatusPollInterval)
status, errMsg, err := m.client.GetAuthStatus(state)
if err != nil {
continue // Ignore transient errors
consecutiveErrors++
if shouldFailOAuthStatusPoll(consecutiveErrors, maxOAuthStatusPollErrors) {
return oauthPollMsg{
state: state,
generation: generation,
done: false,
err: fmt.Errorf("%s: %w", T("oauth_status_error"), err),
}
}
continue
}
consecutiveErrors = 0
switch status {
case "ok":
return oauthPollMsg{
done: true,
message: T("oauth_success"),
state: state,
generation: generation,
done: true,
message: T("oauth_success"),
}
case "error":
return oauthPollMsg{
done: false,
err: fmt.Errorf("%s: %s", T("oauth_failed"), errMsg),
state: state,
generation: generation,
done: false,
err: fmt.Errorf("%s: %s", T("oauth_failed"), errMsg),
}
case "wait":
continue
default:
return oauthPollMsg{
done: true,
message: T("oauth_completed"),
state: state,
generation: generation,
done: true,
message: T("oauth_completed"),
}
}
}
}
}
// shouldAcceptOAuthStart reports whether a start result belongs to the current flow.
func shouldAcceptOAuthStart(msg oauthStartMsg, generation int) bool {
return msg.generation == generation
}
// shouldAcceptOAuthPoll reports whether a poll result belongs to the active remote flow.
func shouldAcceptOAuthPoll(msg oauthPollMsg, authState string, generation int, state oauthState) bool {
if msg.generation != generation {
return false
}
if msg.state == "" || msg.state != authState {
return false
}
return state == oauthRemote
}
// shouldFailOAuthStatusPoll reports whether consecutive status request errors should fail the flow.
func shouldFailOAuthStatusPoll(consecutiveErrors, maxErrors int) bool {
if maxErrors <= 0 {
return consecutiveErrors > 0
}
return consecutiveErrors >= maxErrors
}
func (m *oauthTabModel) SetSize(w, h int) {
m.width = w
m.height = h
@@ -369,9 +498,13 @@ func (m oauthTabModel) renderContent() string {
sb.WriteString("\n\n")
}
// ---- Remote browser mode ----
// ---- Remote browser / device-code mode ----
if m.state == oauthRemote {
sb.WriteString(m.renderRemoteMode())
if m.deviceFlow {
sb.WriteString(m.renderDeviceMode())
} else {
sb.WriteString(m.renderRemoteMode())
}
return sb.String()
}
@@ -450,6 +583,47 @@ func (m oauthTabModel) renderRemoteMode() string {
return sb.String()
}
func (m oauthTabModel) renderDeviceMode() string {
var sb strings.Builder
providerStyle := lipgloss.NewStyle().Bold(true).Foreground(colorHighlight)
sb.WriteString(providerStyle.Render(fmt.Sprintf(" ✦ %s OAuth", m.providerName)))
sb.WriteString("\n\n")
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_auth_url")))
sb.WriteString("\n")
urlStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
maxURLWidth := m.width - 6
if maxURLWidth < 40 {
maxURLWidth = 40
}
for _, line := range wrapText(m.authURL, maxURLWidth) {
sb.WriteString(" " + urlStyle.Render(line) + "\n")
}
sb.WriteString("\n")
if strings.TrimSpace(m.userCode) != "" {
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_user_code")))
sb.WriteString("\n")
codeStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(colorPrimary).Padding(0, 1)
sb.WriteString(" " + codeStyle.Render(m.userCode) + "\n\n")
}
sb.WriteString(helpStyle.Render(T("oauth_device_hint")))
sb.WriteString("\n")
if m.expiresIn > 0 {
sb.WriteString(helpStyle.Render(fmt.Sprintf(T("oauth_device_expires"), m.expiresIn)))
sb.WriteString("\n")
}
sb.WriteString("\n")
sb.WriteString(warningStyle.Render(T("oauth_waiting")))
sb.WriteString("\n")
sb.WriteString(helpStyle.Render(T("oauth_press_esc")))
return sb.String()
}
// wrapText splits a long string into lines of at most maxWidth characters.
func wrapText(s string, maxWidth int) []string {
if maxWidth <= 0 {
+181
View File
@@ -0,0 +1,181 @@
package tui
import (
"testing"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
func TestShouldAcceptOAuthPollFiltersStaleMessages(t *testing.T) {
msg := oauthPollMsg{state: "state-a", generation: 1, done: true, message: "ok"}
if shouldAcceptOAuthPoll(msg, "state-a", 2, oauthRemote) {
t.Fatal("accepted poll with stale generation")
}
if shouldAcceptOAuthPoll(msg, "state-b", 1, oauthRemote) {
t.Fatal("accepted poll with mismatched state")
}
if shouldAcceptOAuthPoll(msg, "state-a", 1, oauthIdle) {
t.Fatal("accepted poll while not in remote state")
}
if !shouldAcceptOAuthPoll(msg, "state-a", 1, oauthRemote) {
t.Fatal("rejected valid poll message")
}
}
func TestShouldAcceptOAuthStartFiltersStaleMessages(t *testing.T) {
msg := oauthStartMsg{state: "state-a", generation: 1, url: "https://example.com"}
if shouldAcceptOAuthStart(msg, 2) {
t.Fatal("accepted start with stale generation")
}
if !shouldAcceptOAuthStart(msg, 1) {
t.Fatal("rejected valid start message")
}
}
func TestShouldFailOAuthStatusPoll(t *testing.T) {
if shouldFailOAuthStatusPoll(4, 5) {
t.Fatal("failed too early on transient errors")
}
if !shouldFailOAuthStatusPoll(5, 5) {
t.Fatal("did not fail after max consecutive errors")
}
if !shouldFailOAuthStatusPoll(1, 0) {
t.Fatal("maxErrors<=0 should fail on first error")
}
}
func TestOAuthTabUpdateIgnoresStalePollMsg(t *testing.T) {
m := newOAuthTabModel(nil)
m.state = oauthRemote
m.authState = "state-current"
m.pollGeneration = 2
m.ready = true
m.viewport = viewport.New(80, 24)
m.viewport.SetContent(m.renderContent())
updated, cmd := m.Update(oauthPollMsg{
state: "state-old",
generation: 1,
done: true,
message: "should be ignored",
})
if cmd != nil {
t.Fatal("expected no command for stale poll")
}
if updated.state != oauthRemote {
t.Fatalf("state = %v, want oauthRemote", updated.state)
}
if updated.message != "" {
t.Fatalf("message changed by stale poll: %q", updated.message)
}
}
func TestOAuthTabUpdateAcceptsCurrentPollMsg(t *testing.T) {
m := newOAuthTabModel(nil)
m.state = oauthRemote
m.authState = "state-current"
m.pollGeneration = 3
m.ready = true
m.viewport = viewport.New(80, 24)
m.viewport.SetContent(m.renderContent())
updated, _ := m.Update(oauthPollMsg{
state: "state-current",
generation: 3,
done: true,
message: "Authentication successful",
})
if updated.state != oauthSuccess {
t.Fatalf("state = %v, want oauthSuccess", updated.state)
}
}
func TestOAuthTabEscRemoteIncrementsGenerationAndClearsState(t *testing.T) {
m := newOAuthTabModel(nil)
m.state = oauthRemote
m.authState = "state-to-cancel"
m.authURL = "https://example.com"
m.deviceFlow = true
m.pollGeneration = 4
m.ready = true
m.viewport = viewport.New(80, 24)
m.viewport.SetContent(m.renderContent())
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
if updated.state != oauthIdle {
t.Fatalf("state = %v, want oauthIdle", updated.state)
}
if updated.pollGeneration != 5 {
t.Fatalf("pollGeneration = %d, want 5", updated.pollGeneration)
}
if updated.authState != "" || updated.authURL != "" || updated.deviceFlow {
t.Fatalf("remote fields not cleared: state=%q url=%q device=%v", updated.authState, updated.authURL, updated.deviceFlow)
}
// client is nil, so cancel command should be nil
if cmd != nil {
t.Fatal("expected nil cancel command when client is nil")
}
}
func TestOAuthTabEscWithActiveCallbackInputCancelsRemoteSession(t *testing.T) {
m := newOAuthTabModel(nil)
m.state = oauthRemote
m.authState = "state-to-cancel"
m.authURL = "https://example.com"
m.deviceFlow = false
m.inputActive = true
m.callbackInput.Focus()
m.callbackInput.SetValue("https://callback.example/?code=abc&state=state-to-cancel")
m.pollGeneration = 7
m.ready = true
m.viewport = viewport.New(80, 24)
m.viewport.SetContent(m.renderContent())
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
if updated.state != oauthIdle {
t.Fatalf("state = %v, want oauthIdle", updated.state)
}
if updated.pollGeneration != 8 {
t.Fatalf("pollGeneration = %d, want 8", updated.pollGeneration)
}
if updated.inputActive {
t.Fatal("inputActive still true after esc cancel")
}
if updated.callbackInput.Value() != "" {
t.Fatalf("callback input not cleared: %q", updated.callbackInput.Value())
}
if updated.authState != "" || updated.authURL != "" {
t.Fatalf("remote fields not cleared: state=%q url=%q", updated.authState, updated.authURL)
}
// client is nil, so cancel command should be nil
if cmd != nil {
t.Fatal("expected nil cancel command when client is nil")
}
}
func TestOAuthTabStaleStartIsIgnored(t *testing.T) {
m := newOAuthTabModel(nil)
m.state = oauthIdle
m.pollGeneration = 2
m.ready = true
m.viewport = viewport.New(80, 24)
m.viewport.SetContent(m.renderContent())
updated, cmd := m.Update(oauthStartMsg{
url: "https://example.com",
state: "stale-state",
generation: 1,
})
if updated.state != oauthIdle {
t.Fatalf("state = %v, want oauthIdle after stale start", updated.state)
}
// client is nil in this unit test; cancel is skipped but state remains idle.
if cmd != nil {
t.Fatal("expected nil cancel command when client is nil")
}
if updated.authState != "" {
t.Fatalf("stale start should not set authState, got %q", updated.authState)
}
}
+26 -176
View File
@@ -3,21 +3,17 @@ package auth
import (
"context"
"fmt"
"net"
"net/http"
"strings"
"time"
xaiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/xai"
"github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
// XAIAuthenticator implements the xAI Grok OAuth loopback flow.
// XAIAuthenticator implements the xAI Grok OAuth device-code flow.
type XAIAuthenticator struct{}
// NewXAIAuthenticator constructs a new xAI authenticator.
@@ -36,7 +32,7 @@ func (XAIAuthenticator) RefreshLead() *time.Duration {
return &lead
}
// Login launches a local OAuth flow to obtain xAI tokens and persists them.
// Login launches the OAuth device-code flow to obtain xAI tokens and persists them.
func (a XAIAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
if cfg == nil {
return nil, fmt.Errorf("cliproxy auth: configuration is required")
@@ -48,137 +44,46 @@ func (a XAIAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *L
opts = &LoginOptions{}
}
callbackPort := xaiauth.CallbackPort
if opts.CallbackPort > 0 {
callbackPort = opts.CallbackPort
}
pkceCodes, err := xaiauth.GeneratePKCECodes()
if err != nil {
return nil, fmt.Errorf("xai pkce generation failed: %w", err)
}
state, err := misc.GenerateRandomState()
if err != nil {
return nil, fmt.Errorf("xai state generation failed: %w", err)
}
nonce, err := misc.GenerateRandomState()
if err != nil {
return nil, fmt.Errorf("xai nonce generation failed: %w", err)
}
authSvc := xaiauth.NewXAIAuth(cfg)
discovery, err := authSvc.Discover(ctx)
fmt.Println("Starting xAI authentication...")
deviceCode, err := authSvc.StartDeviceFlow(ctx)
if err != nil {
return nil, err
return nil, fmt.Errorf("xai: failed to start device flow: %w", err)
}
srv, port, callbackCh, errServer := startXAICallbackServer(callbackPort)
if errServer != nil {
return nil, fmt.Errorf("xai: failed to start callback server: %w", errServer)
verificationURL := strings.TrimSpace(deviceCode.VerificationURIComplete)
if verificationURL == "" {
verificationURL = strings.TrimSpace(deviceCode.VerificationURI)
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if errShutdown := srv.Shutdown(shutdownCtx); errShutdown != nil {
log.Warnf("xai callback server shutdown error: %v", errShutdown)
}
}()
redirectURI := fmt.Sprintf("http://%s:%d%s", xaiauth.RedirectHost, port, xaiauth.RedirectPath)
authURL, err := xaiauth.BuildAuthorizeURL(xaiauth.AuthorizeURLParams{
AuthorizationEndpoint: discovery.AuthorizationEndpoint,
RedirectURI: redirectURI,
CodeChallenge: pkceCodes.CodeChallenge,
State: state,
Nonce: nonce,
})
if err != nil {
return nil, err
fmt.Printf("\nTo authenticate, please visit:\n%s\n\n", verificationURL)
if deviceCode.UserCode != "" {
fmt.Printf("Then enter this code: %s\n\n", deviceCode.UserCode)
}
if !opts.NoBrowser {
fmt.Println("Opening browser for xAI authentication")
if !browser.IsAvailable() {
if browser.IsAvailable() {
if errOpen := browser.OpenURL(verificationURL); errOpen != nil {
log.Warnf("Failed to open browser automatically: %v", errOpen)
} else {
fmt.Println("Browser opened automatically.")
}
} else {
log.Warn("No browser available; please open the URL manually")
util.PrintSSHTunnelInstructions(port)
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
} else if errOpen := browser.OpenURL(authURL); errOpen != nil {
log.Warnf("Failed to open browser automatically: %v", errOpen)
util.PrintSSHTunnelInstructions(port)
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
}
} else {
util.PrintSSHTunnelInstructions(port)
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
}
fmt.Println("Waiting for xAI authentication callback...")
var result callbackResult
timeoutTimer := time.NewTimer(5 * time.Minute)
defer timeoutTimer.Stop()
var manualPromptTimer *time.Timer
var manualPromptC <-chan time.Time
if opts.Prompt != nil {
manualPromptTimer = time.NewTimer(15 * time.Second)
manualPromptC = manualPromptTimer.C
defer manualPromptTimer.Stop()
}
var manualInputCh <-chan string
var manualInputErrCh <-chan error
waitForCallback:
for {
select {
case result = <-callbackCh:
break waitForCallback
case <-manualPromptC:
manualPromptC = nil
if manualPromptTimer != nil {
manualPromptTimer.Stop()
}
select {
case result = <-callbackCh:
break waitForCallback
default:
}
manualInputCh, manualInputErrCh = misc.AsyncPrompt(opts.Prompt, "Paste the xAI callback Token (or press Enter to keep waiting): ")
continue
case input := <-manualInputCh:
manualInputCh = nil
manualInputErrCh = nil
manualResult, ok, errParse := parseXAIManualCallbackToken(input, state)
if errParse != nil {
return nil, errParse
}
if !ok {
continue
}
result = manualResult
break waitForCallback
case errManual := <-manualInputErrCh:
return nil, errManual
case <-timeoutTimer.C:
return nil, fmt.Errorf("xai: authentication timed out")
}
}
if result.Error != "" {
return nil, fmt.Errorf("xai: authentication failed: %s", result.Error)
}
if result.State != state {
return nil, fmt.Errorf("xai: invalid state")
}
if result.Code == "" {
return nil, fmt.Errorf("xai: missing authorization code")
fmt.Println("Waiting for authorization...")
if deviceCode.ExpiresIn > 0 {
fmt.Printf("(This will timeout in %d seconds if not authorized)\n", deviceCode.ExpiresIn)
}
bundle, errExchange := authSvc.ExchangeCodeForTokens(ctx, result.Code, redirectURI, pkceCodes, discovery.TokenEndpoint)
if errExchange != nil {
return nil, fmt.Errorf("xai: token exchange failed: %w", errExchange)
bundle, errWait := authSvc.WaitForAuthorization(ctx, deviceCode)
if errWait != nil {
return nil, fmt.Errorf("xai: %w", errWait)
}
tokenStorage := authSvc.CreateTokenStorage(bundle)
if tokenStorage == nil || strings.TrimSpace(tokenStorage.AccessToken) == "" {
return nil, fmt.Errorf("xai token storage missing access token")
@@ -200,7 +105,6 @@ waitForCallback:
"expired": tokenStorage.Expire,
"last_refresh": tokenStorage.LastRefresh,
"base_url": tokenStorage.BaseURL,
"redirect_uri": tokenStorage.RedirectURI,
"token_endpoint": tokenStorage.TokenEndpoint,
"auth_kind": "oauth",
}
@@ -226,57 +130,3 @@ waitForCallback:
},
}, nil
}
func parseXAIManualCallbackToken(input string, state string) (callbackResult, bool, error) {
token := strings.TrimSpace(input)
if token == "" {
return callbackResult{}, false, nil
}
if strings.Contains(token, "://") || strings.Contains(token, "?") || strings.Contains(token, "code=") {
return callbackResult{}, false, fmt.Errorf("xai: paste only the callback token")
}
return callbackResult{Code: token, State: state}, true, nil
}
func startXAICallbackServer(port int) (*http.Server, int, <-chan callbackResult, error) {
if port <= 0 {
port = xaiauth.CallbackPort
}
addr := fmt.Sprintf("%s:%d", xaiauth.RedirectHost, port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, 0, nil, err
}
port = listener.Addr().(*net.TCPAddr).Port
resultCh := make(chan callbackResult, 1)
mux := http.NewServeMux()
mux.HandleFunc(xaiauth.RedirectPath, func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
result := callbackResult{
Code: strings.TrimSpace(q.Get("code")),
Error: strings.TrimSpace(q.Get("error")),
State: strings.TrimSpace(q.Get("state")),
}
resultCh <- result
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if result.Code != "" && result.Error == "" {
_, _ = w.Write([]byte("<h1>Login successful</h1><p>You can close this window.</p>"))
return
}
_, _ = w.Write([]byte("<h1>Login failed</h1><p>Please check the CLI output.</p>"))
})
srv := &http.Server{
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}
go func() {
if errServe := srv.Serve(listener); errServe != nil && !strings.Contains(errServe.Error(), "Server closed") {
log.Warnf("xai callback server error: %v", errServe)
}
}()
return srv, port, resultCh, nil
}
-23
View File
@@ -12,26 +12,3 @@ func TestXAIAuthenticatorProviderAndRefreshLead(t *testing.T) {
t.Fatalf("RefreshLead() = %v, want positive duration", lead)
}
}
func TestParseXAIManualCallbackTokenAcceptsRawCode(t *testing.T) {
result, ok, err := parseXAIManualCallbackToken(" V0auoESADonzF4bY_Ag2whBFnVeqzHJm6nW2uW012rqCCW5cstFV58qvDFBvnPBXXe0rZSKOcs3PwwfACKp1qg ", "state-1")
if err != nil {
t.Fatalf("parseXAIManualCallbackToken() error = %v", err)
}
if !ok {
t.Fatal("parseXAIManualCallbackToken() ok = false, want true")
}
if result.Code != "V0auoESADonzF4bY_Ag2whBFnVeqzHJm6nW2uW012rqCCW5cstFV58qvDFBvnPBXXe0rZSKOcs3PwwfACKp1qg" {
t.Fatalf("Code = %q", result.Code)
}
if result.State != "state-1" {
t.Fatalf("State = %q, want state-1", result.State)
}
}
func TestParseXAIManualCallbackTokenRejectsCallbackURL(t *testing.T) {
_, _, err := parseXAIManualCallbackToken("http://127.0.0.1:56121/callback?state=state-1&code=token-1", "state-1")
if err == nil {
t.Fatal("parseXAIManualCallbackToken() error = nil, want error")
}
}