mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 01:16:47 +08:00
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:
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user