vibe-proxy/backend/internal/api/handlers/management/auth_files_provider_oauth.go
2026-08-24 00:10:41 +02:00

888 lines
29 KiB
Go

package management
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/antigravity"
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kimi"
xaiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/xai"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
type codexOAuthService interface {
GenerateAuthURL(state string, pkceCodes *codex.PKCECodes) (string, error)
ExchangeCodeForTokens(ctx context.Context, code string, pkceCodes *codex.PKCECodes) (*codex.CodexAuthBundle, error)
CreateTokenStorage(bundle *codex.CodexAuthBundle) *codex.CodexTokenStorage
}
func (h *Handler) RequestAnthropicToken(c *gin.Context) {
ctx := context.Background()
ctx = PopulateAuthContext(ctx, c)
fmt.Println("Initializing Claude authentication...")
// Generate PKCE codes
pkceCodes, err := claude.GeneratePKCECodes()
if err != nil {
log.Errorf("Failed to generate PKCE codes: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PKCE codes"})
return
}
// Generate random state parameter
state, err := misc.GenerateRandomState()
if err != nil {
log.Errorf("Failed to generate state parameter: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"})
return
}
// Initialize Claude auth service
anthropicAuth := claude.NewClaudeAuth(h.cfg)
// Generate authorization URL (then override redirect_uri to reuse server port)
authURL, state, err := anthropicAuth.GenerateAuthURL(state, pkceCodes)
if err != nil {
log.Errorf("Failed to generate authorization URL: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
return
}
RegisterOAuthSession(state, "anthropic")
isWebUI := isWebUIRequest(c)
var forwarder *callbackForwarder
if isWebUI {
targetURL, errTarget := h.managementCallbackURL("/anthropic/callback")
if errTarget != nil {
log.WithError(errTarget).Error("failed to compute anthropic callback target")
c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
return
}
var errStart error
if forwarder, errStart = startCallbackForwarder(anthropicCallbackPort, "anthropic", targetURL); errStart != nil {
log.WithError(errStart).Error("failed to start anthropic callback forwarder")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
return
}
}
go func() {
if isWebUI {
defer stopCallbackForwarderInstance(anthropicCallbackPort, forwarder)
}
// Helper: wait for callback file
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-anthropic-%s.oauth", state))
waitForFile := func(path string, timeout time.Duration) (map[string]string, error) {
deadline := time.Now().Add(timeout)
for {
if !IsOAuthSessionPending(state, "anthropic") {
return nil, errOAuthSessionNotPending
}
if time.Now().After(deadline) {
SetOAuthSessionError(state, "Timeout waiting for OAuth callback")
return nil, fmt.Errorf("timeout waiting for OAuth callback")
}
data, errRead := os.ReadFile(path)
if errRead == nil {
var m map[string]string
_ = json.Unmarshal(data, &m)
_ = os.Remove(path)
return m, nil
}
time.Sleep(500 * time.Millisecond)
}
}
fmt.Println("Waiting for authentication callback...")
// Wait up to 5 minutes
resultMap, errWait := waitForFile(waitFile, 5*time.Minute)
if errWait != nil {
if errors.Is(errWait, errOAuthSessionNotPending) {
return
}
authErr := claude.NewAuthenticationError(claude.ErrCallbackTimeout, errWait)
log.Error(claude.GetUserFriendlyMessage(authErr))
return
}
if errStr := resultMap["error"]; errStr != "" {
oauthErr := claude.NewOAuthError(errStr, "", http.StatusBadRequest)
log.Error(claude.GetUserFriendlyMessage(oauthErr))
SetOAuthSessionError(state, "Bad request")
return
}
if resultMap["state"] != state {
authErr := claude.NewAuthenticationError(claude.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, resultMap["state"]))
log.Error(claude.GetUserFriendlyMessage(authErr))
SetOAuthSessionError(state, "State code error")
return
}
// Parse code (Claude may append state after '#')
rawCode := resultMap["code"]
code := strings.Split(rawCode, "#")[0]
// Exchange code for tokens using internal auth service
bundle, errExchange := anthropicAuth.ExchangeCodeForTokens(ctx, code, state, pkceCodes)
if errExchange != nil {
authErr := claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, errExchange)
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
SetOAuthSessionError(state, "Failed to exchange authorization code for tokens")
return
}
// Create token storage
tokenStorage := anthropicAuth.CreateTokenStorage(bundle)
metadata := map[string]any{"email": tokenStorage.Email}
if tokenStorage.AccountUUID != "" {
metadata["account_uuid"] = tokenStorage.AccountUUID
}
if tokenStorage.OrganizationUUID != "" {
metadata["organization_uuid"] = tokenStorage.OrganizationUUID
}
if tokenStorage.OrganizationName != "" {
metadata["organization_name"] = tokenStorage.OrganizationName
}
if len(tokenStorage.DeviceIDs) > 0 {
metadata[claude.ClaudeDeviceIDsMetadataKey] = append([]string(nil), tokenStorage.DeviceIDs...)
}
record := &coreauth.Auth{
ID: fmt.Sprintf("claude-%s.json", tokenStorage.Email),
Provider: "claude",
FileName: fmt.Sprintf("claude-%s.json", tokenStorage.Email),
Storage: tokenStorage,
Metadata: metadata,
}
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)
SetOAuthSessionError(state, "Failed to save authentication tokens")
return
}
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
if bundle.APIKey != "" {
fmt.Println("API key obtained and saved")
}
fmt.Println("You can now use Claude services through this CLI")
CompleteOAuthSession(state)
}()
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
}
func (h *Handler) RequestCodexToken(c *gin.Context) {
ctx := context.Background()
ctx = PopulateAuthContext(ctx, c)
fmt.Println("Initializing Codex authentication...")
// Generate PKCE codes
pkceCodes, err := codex.GeneratePKCECodes()
if err != nil {
log.Errorf("Failed to generate PKCE codes: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PKCE codes"})
return
}
// Generate random state parameter
state, err := misc.GenerateRandomState()
if err != nil {
log.Errorf("Failed to generate state parameter: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"})
return
}
// Initialize Codex auth service
openaiAuth := newCodexOAuthService(h.cfg)
// Generate authorization URL
authURL, err := openaiAuth.GenerateAuthURL(state, pkceCodes)
if err != nil {
log.Errorf("Failed to generate authorization URL: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
return
}
RegisterOAuthSession(state, "codex")
isWebUI := isWebUIRequest(c)
var forwarder *callbackForwarder
if isWebUI {
targetURL, errTarget := h.managementCallbackURL("/codex/callback")
if errTarget != nil {
log.WithError(errTarget).Error("failed to compute codex callback target")
c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
return
}
var errStart error
if forwarder, errStart = startCallbackForwarder(codexCallbackPort, "codex", targetURL); errStart != nil {
log.WithError(errStart).Error("failed to start codex callback forwarder")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
return
}
}
go func() {
if isWebUI {
defer stopCallbackForwarderInstance(codexCallbackPort, forwarder)
}
// Wait for callback file
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-codex-%s.oauth", state))
deadline := time.Now().Add(5 * time.Minute)
var code string
for {
if !IsOAuthSessionPending(state, "codex") {
return
}
if time.Now().After(deadline) {
authErr := codex.NewAuthenticationError(codex.ErrCallbackTimeout, fmt.Errorf("timeout waiting for OAuth callback"))
log.Error(codex.GetUserFriendlyMessage(authErr))
SetOAuthSessionError(state, "Timeout waiting for OAuth callback")
return
}
if data, errR := os.ReadFile(waitFile); errR == nil {
var m map[string]string
_ = json.Unmarshal(data, &m)
_ = os.Remove(waitFile)
if errStr := m["error"]; errStr != "" {
oauthErr := codex.NewOAuthError(errStr, "", http.StatusBadRequest)
log.Error(codex.GetUserFriendlyMessage(oauthErr))
SetOAuthSessionError(state, "Bad Request")
return
}
if m["state"] != state {
authErr := codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, m["state"]))
SetOAuthSessionError(state, "State code error")
log.Error(codex.GetUserFriendlyMessage(authErr))
return
}
code = m["code"]
break
}
time.Sleep(500 * time.Millisecond)
}
log.Debug("Authorization code received, exchanging for tokens...")
// Exchange code for tokens using internal auth service
bundle, errExchange := openaiAuth.ExchangeCodeForTokens(ctx, code, pkceCodes)
if errExchange != nil {
authErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, errExchange)
SetOAuthSessionError(state, oauthSessionErrorWithCause("Failed to exchange authorization code for tokens", errExchange))
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
return
}
// Extract additional info for filename generation
claims, _ := codex.ParseJWTToken(bundle.TokenData.IDToken)
planType := ""
hashAccountID := ""
if claims != nil {
planType = strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType)
if accountID := claims.GetAccountID(); accountID != "" {
digest := sha256.Sum256([]byte(accountID))
hashAccountID = hex.EncodeToString(digest[:])[:8]
}
}
// Create token storage and persist
tokenStorage := openaiAuth.CreateTokenStorage(bundle)
fileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true)
record := &coreauth.Auth{
ID: fileName,
Provider: "codex",
FileName: fileName,
Storage: tokenStorage,
Metadata: map[string]any{
"email": tokenStorage.Email,
"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")
log.Errorf("Failed to save authentication tokens: %v", errSave)
return
}
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
if bundle.APIKey != "" {
fmt.Println("API key obtained and saved")
}
fmt.Println("You can now use Codex services through this CLI")
CompleteOAuthSession(state)
}()
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
}
func (h *Handler) RequestAntigravityToken(c *gin.Context) {
ctx := context.Background()
ctx = PopulateAuthContext(ctx, c)
fmt.Println("Initializing Antigravity authentication...")
authSvc := antigravity.NewAntigravityAuth(h.cfg, nil)
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
}
redirectURI := fmt.Sprintf("http://localhost:%d/oauth-callback", antigravity.CallbackPort)
authURL := authSvc.BuildAuthURL(state, redirectURI)
RegisterOAuthSession(state, "antigravity")
isWebUI := isWebUIRequest(c)
var forwarder *callbackForwarder
if isWebUI {
targetURL, errTarget := h.managementCallbackURL("/antigravity/callback")
if errTarget != nil {
log.WithError(errTarget).Error("failed to compute antigravity callback target")
c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
return
}
var errStart error
if forwarder, errStart = startCallbackForwarder(antigravity.CallbackPort, "antigravity", targetURL); errStart != nil {
log.WithError(errStart).Error("failed to start antigravity callback forwarder")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
return
}
}
go func() {
if isWebUI {
defer stopCallbackForwarderInstance(antigravity.CallbackPort, forwarder)
}
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-antigravity-%s.oauth", state))
deadline := time.Now().Add(5 * time.Minute)
var authCode string
for {
if !IsOAuthSessionPending(state, "antigravity") {
return
}
if time.Now().After(deadline) {
log.Error("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("Authentication failed: %s", errStr)
SetOAuthSessionError(state, "Authentication failed")
return
}
if payloadState := strings.TrimSpace(payload["state"]); payloadState != "" && payloadState != state {
log.Errorf("Authentication failed: state mismatch")
SetOAuthSessionError(state, "Authentication failed: state mismatch")
return
}
authCode = strings.TrimSpace(payload["code"])
if authCode == "" {
log.Error("Authentication failed: code not found")
SetOAuthSessionError(state, "Authentication failed: code not found")
return
}
break
}
time.Sleep(500 * time.Millisecond)
}
tokenResp, errToken := authSvc.ExchangeCodeForTokens(ctx, authCode, redirectURI)
if errToken != nil {
log.Errorf("Failed to exchange token: %v", errToken)
SetOAuthSessionError(state, "Failed to exchange token")
return
}
accessToken := strings.TrimSpace(tokenResp.AccessToken)
if accessToken == "" {
log.Error("antigravity: token exchange returned empty access token")
SetOAuthSessionError(state, "Failed to exchange token")
return
}
email, errInfo := authSvc.FetchUserInfo(ctx, accessToken)
if errInfo != nil {
log.Errorf("Failed to fetch user info: %v", errInfo)
SetOAuthSessionError(state, "Failed to fetch user info")
return
}
email = strings.TrimSpace(email)
if email == "" {
log.Error("antigravity: user info returned empty email")
SetOAuthSessionError(state, "Failed to fetch user info")
return
}
projectID := ""
if accessToken != "" {
fetchedProjectID, errProject := authSvc.FetchProjectID(ctx, accessToken)
if errProject != nil {
log.Warnf("antigravity: failed to fetch project ID: %v", errProject)
} else {
projectID = fetchedProjectID
log.Infof("antigravity: obtained project ID %s", util.HideAPIKey(projectID))
}
}
now := time.Now()
metadata := map[string]any{
"type": "antigravity",
"access_token": tokenResp.AccessToken,
"refresh_token": tokenResp.RefreshToken,
"expires_in": tokenResp.ExpiresIn,
"timestamp": now.UnixMilli(),
"expired": now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
}
if email != "" {
metadata["email"] = email
}
if projectID != "" {
metadata["project_id"] = projectID
}
fileName := antigravity.CredentialFileName(email)
label := strings.TrimSpace(email)
if label == "" {
label = "antigravity"
}
record := &coreauth.Auth{
ID: fileName,
Provider: "antigravity",
FileName: fileName,
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)
SetOAuthSessionError(state, "Failed to save token to file")
return
}
CompleteOAuthSession(state)
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
if projectID != "" {
fmt.Printf("Using GCP project: %s\n", util.HideAPIKey(projectID))
}
fmt.Println("You can now use Antigravity services through this CLI")
}()
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
}
func (h *Handler) RequestXAIToken(c *gin.Context) {
ctx := context.Background()
ctx = PopulateAuthContext(ctx, c)
fmt.Println("Initializing xAI authentication...")
state := fmt.Sprintf("xai-%d", time.Now().UnixNano())
authSvc := xaiauth.NewXAIAuth(h.cfg)
deviceFlow, errStartDeviceFlow := authSvc.StartDeviceFlow(ctx)
if errStartDeviceFlow != nil {
log.Errorf("Failed to start xAI device flow: %v", errStartDeviceFlow)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start device authorization flow"})
return
}
authURL := strings.TrimSpace(deviceFlow.VerificationURIComplete)
if authURL == "" {
authURL = strings.TrimSpace(deviceFlow.VerificationURI)
}
RegisterOAuthSession(state, "xai")
go func() {
pollCtx, cancelPoll := context.WithCancel(ctx)
defer cancelPoll()
go watchOAuthSessionCancel(pollCtx, cancelPoll, state, "xai")
fmt.Println("Waiting for xAI authentication...")
bundle, errWaitForAuthorization := authSvc.WaitForAuthorization(pollCtx, deviceFlow)
if errWaitForAuthorization != nil {
if !IsOAuthSessionPending(state, "xai") {
return
}
log.Errorf("xAI authentication failed: %v", errWaitForAuthorization)
SetOAuthSessionError(state, oauthSessionErrorWithCause("Authentication failed", errWaitForAuthorization))
return
}
if !IsOAuthSessionPending(state, "xai") {
return
}
tokenStorage := authSvc.CreateTokenStorage(bundle)
if tokenStorage == nil || strings.TrimSpace(tokenStorage.AccessToken) == "" {
log.Error("xAI token exchange returned empty access token")
SetOAuthSessionError(state, "Failed to exchange token")
return
}
fileName := xaiauth.CredentialFileName(tokenStorage.Email, tokenStorage.Subject)
label := strings.TrimSpace(tokenStorage.Email)
if label == "" {
label = "xAI"
}
metadata := map[string]any{
"type": "xai",
"access_token": tokenStorage.AccessToken,
"refresh_token": tokenStorage.RefreshToken,
"id_token": tokenStorage.IDToken,
"token_type": tokenStorage.TokenType,
"expires_in": tokenStorage.ExpiresIn,
"expired": tokenStorage.Expire,
"last_refresh": tokenStorage.LastRefresh,
"base_url": tokenStorage.BaseURL,
"token_endpoint": tokenStorage.TokenEndpoint,
"auth_kind": "oauth",
}
if tokenStorage.Email != "" {
metadata["email"] = tokenStorage.Email
}
if tokenStorage.Subject != "" {
metadata["sub"] = tokenStorage.Subject
}
record := &coreauth.Auth{
ID: fileName,
Provider: "xai",
FileName: fileName,
Label: label,
Storage: tokenStorage,
Metadata: metadata,
Attributes: map[string]string{
"auth_kind": "oauth",
"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)
SetOAuthSessionError(state, "Failed to save token to file")
return
}
CompleteOAuthSession(state)
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
fmt.Println("You can now use xAI services through this CLI")
}()
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) {
ctx := context.Background()
ctx = PopulateAuthContext(ctx, c)
fmt.Println("Initializing Kimi authentication...")
state := fmt.Sprintf("kmi-%d", time.Now().UnixNano())
// Initialize Kimi auth service
kimiAuth := kimi.NewKimiAuth(h.cfg)
// Generate authorization URL
deviceFlow, errStartDeviceFlow := kimiAuth.StartDeviceFlow(ctx)
if errStartDeviceFlow != nil {
log.Errorf("Failed to generate authorization URL: %v", errStartDeviceFlow)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
return
}
authURL := deviceFlow.VerificationURIComplete
if authURL == "" {
authURL = deviceFlow.VerificationURI
}
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(pollCtx, deviceFlow)
if errWaitForAuthorization != nil {
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)
metadata := map[string]any{
"type": "kimi",
"access_token": authBundle.TokenData.AccessToken,
"refresh_token": authBundle.TokenData.RefreshToken,
"token_type": authBundle.TokenData.TokenType,
"scope": authBundle.TokenData.Scope,
"timestamp": time.Now().UnixMilli(),
}
if authBundle.TokenData.ExpiresAt > 0 {
expired := time.Unix(authBundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339)
metadata["expired"] = expired
}
if strings.TrimSpace(authBundle.DeviceID) != "" {
metadata["device_id"] = strings.TrimSpace(authBundle.DeviceID)
}
fileName := fmt.Sprintf("kimi-%d.json", time.Now().UnixMilli())
record := &coreauth.Auth{
ID: fileName,
Provider: "kimi",
FileName: fileName,
Label: "Kimi User",
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)
SetOAuthSessionError(state, "Failed to save authentication tokens")
return
}
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
fmt.Println("You can now use Kimi services through this CLI")
CompleteOAuthSession(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) {
state := strings.TrimSpace(c.Query("state"))
if state == "" {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
return
}
if err := ValidateOAuthState(state); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid state"})
return
}
provider, status, isPlugin, metadata, completed, ok := GetOAuthSessionDetails(state)
if !ok {
c.JSON(http.StatusOK, gin.H{"status": "error", "error": "unknown or expired state"})
return
}
if completed {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
return
}
if status != "" {
c.JSON(http.StatusOK, gin.H{"status": "error", "error": status})
return
}
h.mu.Lock()
host := h.pluginHost
h.mu.Unlock()
if isPlugin && host != nil && host.HasAuthProvider(provider) {
ctx := PopulateAuthContext(context.Background(), c)
resp, handled, errPoll := host.PollLogin(ctx, provider, state, metadata)
if handled {
if errPoll != nil {
message := strings.TrimSpace(errPoll.Error())
if message == "" {
message = "Authentication failed"
}
SetOAuthSessionError(state, message)
c.JSON(http.StatusOK, gin.H{"status": "error", "error": message})
return
}
switch resp.Status {
case "", pluginapi.AuthLoginStatusPending:
c.JSON(http.StatusOK, gin.H{"status": "wait"})
return
case pluginapi.AuthLoginStatusError:
message := strings.TrimSpace(resp.Message)
if message == "" {
message = "Authentication failed"
}
SetOAuthSessionError(state, message)
c.JSON(http.StatusOK, gin.H{"status": "error", "error": message})
return
case pluginapi.AuthLoginStatusSuccess:
records := pluginLoginPollAuths(host, resp)
if len(records) == 0 {
SetOAuthSessionError(state, "Authentication failed")
c.JSON(http.StatusOK, gin.H{"status": "error", "error": "Authentication failed"})
return
}
if errSave := h.savePluginLoginRecords(ctx, records); errSave != nil {
log.WithError(errSave).WithField("provider", provider).Error("failed to save plugin auth tokens")
SetOAuthSessionError(state, "Failed to save authentication tokens")
c.JSON(http.StatusOK, gin.H{"status": "error", "error": "Failed to save authentication tokens"})
return
}
CompleteOAuthSession(state)
c.JSON(http.StatusOK, gin.H{"status": "ok"})
return
default:
c.JSON(http.StatusOK, gin.H{"status": "wait"})
return
}
}
}
c.JSON(http.StatusOK, gin.H{"status": "wait"})
}
func pluginLoginPollAuths(host *pluginhost.Host, resp pluginapi.AuthLoginPollResponse) []*coreauth.Auth {
if host == nil {
return nil
}
authDatas := resp.Auths
if len(authDatas) == 0 {
authDatas = []pluginapi.AuthData{resp.Auth}
}
records := make([]*coreauth.Auth, 0, len(authDatas))
for _, authData := range authDatas {
record := host.AuthDataToCoreAuth(authData, "", "")
if record == nil {
return nil
}
records = append(records, record)
}
return records
}
func (h *Handler) savePluginLoginRecords(ctx context.Context, records []*coreauth.Auth) error {
savedPaths := make([]string, 0, len(records))
for _, record := range records {
savedPath, errSave := h.saveTokenRecord(ctx, record)
if strings.TrimSpace(savedPath) != "" {
savedPaths = append(savedPaths, savedPath)
}
if errSave != nil {
h.rollbackSavedTokenRecords(ctx, savedPaths)
return errSave
}
}
return nil
}
func (h *Handler) rollbackSavedTokenRecords(ctx context.Context, savedPaths []string) {
for i := len(savedPaths) - 1; i >= 0; i-- {
path := strings.TrimSpace(savedPaths[i])
if path == "" {
continue
}
if errDelete := h.deleteTokenRecord(ctx, path); errDelete != nil {
log.WithError(errDelete).WithField("path", path).Warn("failed to roll back plugin auth token")
}
h.removeAuthsForPath(ctx, path, path)
}
}
// PopulateAuthContext extracts request info and adds it to the context
func PopulateAuthContext(ctx context.Context, c *gin.Context) context.Context {
info := &coreauth.RequestInfo{
Query: c.Request.URL.Query(),
Headers: c.Request.Header,
}
return coreauth.WithRequestInfo(ctx, info)
}