diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index ecbdf851..17f20286 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -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"}) - 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"}) + 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 } + 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) { diff --git a/internal/api/handlers/management/oauth_callback_test.go b/internal/api/handlers/management/oauth_callback_test.go index 832423bb..0d2e8ded 100644 --- a/internal/api/handlers/management/oauth_callback_test.go +++ b/internal/api/handlers/management/oauth_callback_test.go @@ -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") diff --git a/internal/api/handlers/management/oauth_sessions.go b/internal/api/handlers/management/oauth_sessions.go index f24eb4eb..d370d92a 100644 --- a/internal/api/handlers/management/oauth_sessions.go +++ b/internal/api/handlers/management/oauth_sessions.go @@ -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 == "" { diff --git a/internal/api/handlers/management/oauth_sessions_test.go b/internal/api/handlers/management/oauth_sessions_test.go index 3e70541b..cce61b2d 100644 --- a/internal/api/handlers/management/oauth_sessions_test.go +++ b/internal/api/handlers/management/oauth_sessions_test.go @@ -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 diff --git a/internal/api/server.go b/internal/api/server.go index f7bce5d9..0117a488 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) } } diff --git a/internal/auth/xai/pkce.go b/internal/auth/xai/pkce.go deleted file mode 100644 index 54d2c23d..00000000 --- a/internal/auth/xai/pkce.go +++ /dev/null @@ -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 -} diff --git a/internal/auth/xai/types.go b/internal/auth/xai/types.go index c41f7ebc..93a5ebab 100644 --- a/internal/auth/xai/types.go +++ b/internal/auth/xai/types.go @@ -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. diff --git a/internal/auth/xai/xai.go b/internal/auth/xai/xai.go index 6049a75d..65d988c3 100644 --- a/internal/auth/xai/xai.go +++ b/internal/auth/xai/xai.go @@ -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,47 +104,229 @@ 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) == "" { - discovery, errDiscover := a.Discover(ctx) - if errDiscover != nil { - return nil, errDiscover + + 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) } - tokenEndpoint = discovery.TokenEndpoint + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("xai device code: read response: %w", err) } - form := url.Values{ - "grant_type": {"authorization_code"}, - "code": {strings.TrimSpace(code)}, - "redirect_uri": {strings.TrimSpace(redirectURI)}, - "client_id": {ClientID}, - "code_verifier": {pkceCodes.CodeVerifier}, + 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") } - tokenData, err := a.postTokenForm(ctx, tokenEndpoint, form) + 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, - RedirectURI: strings.TrimSpace(redirectURI), - TokenEndpoint: strings.TrimSpace(tokenEndpoint), + 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) == "" { @@ -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 { diff --git a/internal/auth/xai/xai_auth_test.go b/internal/auth/xai/xai_auth_test.go index 199e8f8c..9554f8c2 100644 --- a/internal/auth/xai/xai_auth_test.go +++ b/internal/auth/xai/xai_auth_test.go @@ -2,6 +2,7 @@ package xai import ( "context" + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" @@ -19,55 +20,199 @@ 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", - }) +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("BuildAuthorizeURL() error = %v", err) + 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) + } - parsed, errParse := url.Parse(authURL) - if errParse != nil { - t.Fatalf("parse authorize URL: %v", errParse) + 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 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) + 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() - 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", + 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) } - for key, value := range want { - if got := query.Get(key); got != value { - t.Fatalf("%s = %q, want %q", key, got, value) +} + +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 TestValidateOAuthEndpointRejectsNonXAIOrigin(t *testing.T) { - if _, err := ValidateOAuthEndpoint("https://auth.x.ai/oauth/token", "token_endpoint"); err != nil { - t.Fatalf("ValidateOAuthEndpoint(xai) error = %v", err) +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) } - if _, err := ValidateOAuthEndpoint("http://auth.x.ai/oauth/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") + tokenData = buildTokenData("access", "refresh", "", "Bearer", 60, "user@x.ai", "sub-1") + if tokenData.Expire == "" { + t.Fatal("Expire empty, want RFC3339 timestamp") } } @@ -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" +} diff --git a/internal/cmd/xai_login.go b/internal/cmd/xai_login.go index c0349043..88d9d7ff 100644 --- a/internal/cmd/xai_login.go +++ b/internal/cmd/xai_login.go @@ -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{} diff --git a/internal/tui/client.go b/internal/tui/client.go index 130b5395..397b0996 100644 --- a/internal/tui/client.go +++ b/internal/tui/client.go @@ -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. diff --git a/internal/tui/i18n.go b/internal/tui/i18n.go index 64227b34..1c46cb5f 100644 --- a/internal/tui/i18n.go +++ b/internal/tui/i18n.go @@ -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", diff --git a/internal/tui/oauth_tab.go b/internal/tui/oauth_tab.go index 1cfe1a1a..4eb03b0b 100644 --- a/internal/tui/oauth_tab.go +++ b/internal/tui/oauth_tab.go @@ -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 { diff --git a/internal/tui/oauth_tab_test.go b/internal/tui/oauth_tab_test.go new file mode 100644 index 00000000..d8b3d411 --- /dev/null +++ b/internal/tui/oauth_tab_test.go @@ -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) + } +} diff --git a/sdk/auth/xai.go b/sdk/auth/xai.go index 1ab248d6..039878b2 100644 --- a/sdk/auth/xai.go +++ b/sdk/auth/xai.go @@ -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 - } + authSvc := xaiauth.NewXAIAuth(cfg) - 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() + fmt.Println("Starting xAI authentication...") + deviceCode, err := authSvc.StartDeviceFlow(ctx) if err != nil { - return nil, fmt.Errorf("xai nonce generation failed: %w", err) + return nil, fmt.Errorf("xai: failed to start device flow: %w", err) } - authSvc := xaiauth.NewXAIAuth(cfg) - discovery, err := authSvc.Discover(ctx) - if err != nil { - return nil, err + verificationURL := strings.TrimSpace(deviceCode.VerificationURIComplete) + if verificationURL == "" { + verificationURL = strings.TrimSpace(deviceCode.VerificationURI) } - srv, port, callbackCh, errServer := startXAICallbackServer(callbackPort) - if errServer != nil { - return nil, fmt.Errorf("xai: failed to start callback server: %w", errServer) - } - 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") - } + fmt.Println("Waiting for authorization...") + if deviceCode.ExpiresIn > 0 { + fmt.Printf("(This will timeout in %d seconds if not authorized)\n", deviceCode.ExpiresIn) } - 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") + bundle, errWait := authSvc.WaitForAuthorization(ctx, deviceCode) + if errWait != nil { + return nil, fmt.Errorf("xai: %w", errWait) } - bundle, errExchange := authSvc.ExchangeCodeForTokens(ctx, result.Code, redirectURI, pkceCodes, discovery.TokenEndpoint) - if errExchange != nil { - return nil, fmt.Errorf("xai: token exchange failed: %w", errExchange) - } 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("
You can close this window.
")) - return - } - _, _ = w.Write([]byte("Please check the CLI output.
")) - }) - - 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 -} diff --git a/sdk/auth/xai_test.go b/sdk/auth/xai_test.go index 6d755d0d..4d79d561 100644 --- a/sdk/auth/xai_test.go +++ b/sdk/auth/xai_test.go @@ -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") - } -}