diff --git a/go.mod b/go.mod index 80beff76ee..2dcc93bab7 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/kooshapari/cliproxyapi-plusplus/v6 go 1.26.0 require ( + github.com/KooshaPari/phenotype-go-kit v0.0.0 github.com/andybalholm/brotli v1.2.0 github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v1.0.0 @@ -110,3 +111,5 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) + +replace github.com/KooshaPari/phenotype-go-kit => ../../template-commons/phenotype-go-kit diff --git a/internal/auth/claude/anthropic_auth.go b/internal/auth/claude/anthropic_auth.go new file mode 100644 index 0000000000..8ce2704761 --- /dev/null +++ b/internal/auth/claude/anthropic_auth.go @@ -0,0 +1,348 @@ +// Package claude provides OAuth2 authentication functionality for Anthropic's Claude API. +// This package implements the complete OAuth2 flow with PKCE (Proof Key for Code Exchange) +// for secure authentication with Claude API, including token exchange, refresh, and storage. +package claude + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config" + log "github.com/sirupsen/logrus" +) + +// OAuth configuration constants for Claude/Anthropic +const ( + AuthURL = "https://claude.ai/oauth/authorize" + TokenURL = "https://api.anthropic.com/v1/oauth/token" + ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + RedirectURI = "http://localhost:54545/callback" +) + +// tokenResponse represents the response structure from Anthropic's OAuth token endpoint. +// It contains access token, refresh token, and associated user/organization information. +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Organization struct { + UUID string `json:"uuid"` + Name string `json:"name"` + } `json:"organization"` + Account struct { + UUID string `json:"uuid"` + EmailAddress string `json:"email_address"` + } `json:"account"` +} + +// ClaudeAuth handles Anthropic OAuth2 authentication flow. +// It provides methods for generating authorization URLs, exchanging codes for tokens, +// and refreshing expired tokens using PKCE for enhanced security. +type ClaudeAuth struct { + httpClient *http.Client +} + +// NewClaudeAuth creates a new Anthropic authentication service. +// It initializes the HTTP client with a custom TLS transport that uses Firefox +// fingerprint to bypass Cloudflare's TLS fingerprinting on Anthropic domains. +// +// Parameters: +// - cfg: The application configuration containing proxy settings +// +// Returns: +// - *ClaudeAuth: A new Claude authentication service instance +func NewClaudeAuth(cfg *config.Config) *ClaudeAuth { + // Use custom HTTP client with Firefox TLS fingerprint to bypass + // Cloudflare's bot detection on Anthropic domains + return &ClaudeAuth{ + httpClient: NewAnthropicHttpClient(&cfg.SDKConfig), + } +} + +// GenerateAuthURL creates the OAuth authorization URL with PKCE. +// This method generates a secure authorization URL including PKCE challenge codes +// for the OAuth2 flow with Anthropic's API. +// +// Parameters: +// - state: A random state parameter for CSRF protection +// - pkceCodes: The PKCE codes for secure code exchange +// +// Returns: +// - string: The complete authorization URL +// - string: The state parameter for verification +// - error: An error if PKCE codes are missing or URL generation fails +func (o *ClaudeAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, string, error) { + if pkceCodes == nil { + return "", "", fmt.Errorf("PKCE codes are required") + } + + params := url.Values{ + "code": {"true"}, + "client_id": {ClientID}, + "response_type": {"code"}, + "redirect_uri": {RedirectURI}, + "scope": {"org:create_api_key user:profile user:inference"}, + "code_challenge": {pkceCodes.CodeChallenge}, + "code_challenge_method": {"S256"}, + "state": {state}, + } + + authURL := fmt.Sprintf("%s?%s", AuthURL, params.Encode()) + return authURL, state, nil +} + +// parseCodeAndState extracts the authorization code and state from the callback response. +// It handles the parsing of the code parameter which may contain additional fragments. +// +// Parameters: +// - code: The raw code parameter from the OAuth callback +// +// Returns: +// - parsedCode: The extracted authorization code +// - parsedState: The extracted state parameter if present +func (c *ClaudeAuth) parseCodeAndState(code string) (parsedCode, parsedState string) { + splits := strings.Split(code, "#") + parsedCode = splits[0] + if len(splits) > 1 { + parsedState = splits[1] + } + return +} + +// ExchangeCodeForTokens exchanges authorization code for access tokens. +// This method implements the OAuth2 token exchange flow using PKCE for security. +// It sends the authorization code along with PKCE verifier to get access and refresh tokens. +// +// Parameters: +// - ctx: The context for the request +// - code: The authorization code received from OAuth callback +// - state: The state parameter for verification +// - pkceCodes: The PKCE codes for secure verification +// +// Returns: +// - *ClaudeAuthBundle: The complete authentication bundle with tokens +// - error: An error if token exchange fails +func (o *ClaudeAuth) ExchangeCodeForTokens(ctx context.Context, code, state string, pkceCodes *PKCECodes) (*ClaudeAuthBundle, error) { + if pkceCodes == nil { + return nil, fmt.Errorf("PKCE codes are required for token exchange") + } + newCode, newState := o.parseCodeAndState(code) + + // Prepare token exchange request + reqBody := map[string]interface{}{ + "code": newCode, + "state": state, + "grant_type": "authorization_code", + "client_id": ClientID, + "redirect_uri": RedirectURI, + "code_verifier": pkceCodes.CodeVerifier, + } + + // Include state if present + if newState != "" { + reqBody["state"] = newState + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + + // log.Debugf("Token exchange request: %s", string(jsonBody)) + + req, err := http.NewRequestWithContext(ctx, "POST", TokenURL, strings.NewReader(string(jsonBody))) + if err != nil { + return nil, fmt.Errorf("failed to create token request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := o.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("token exchange request failed: %w", err) + } + defer func() { + if errClose := resp.Body.Close(); errClose != nil { + log.Errorf("failed to close response body: %v", errClose) + } + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read token response: %w", err) + } + // log.Debugf("Token response: %s", string(body)) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) + } + // log.Debugf("Token response: %s", string(body)) + + var tokenResp tokenResponse + if err = json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to parse token response: %w", err) + } + + // Create token data + tokenData := ClaudeTokenData{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + Email: tokenResp.Account.EmailAddress, + Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339), + } + + // Create auth bundle + bundle := &ClaudeAuthBundle{ + TokenData: tokenData, + LastRefresh: time.Now().Format(time.RFC3339), + } + + return bundle, nil +} + +// RefreshTokens refreshes the access token using the refresh token. +// This method exchanges a valid refresh token for a new access token, +// extending the user's authenticated session. +// +// Parameters: +// - ctx: The context for the request +// - refreshToken: The refresh token to use for getting new access token +// +// Returns: +// - *ClaudeTokenData: The new token data with updated access token +// - error: An error if token refresh fails +func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) { + if refreshToken == "" { + return nil, fmt.Errorf("refresh token is required") + } + + reqBody := map[string]interface{}{ + "client_id": ClientID, + "grant_type": "refresh_token", + "refresh_token": refreshToken, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", TokenURL, strings.NewReader(string(jsonBody))) + if err != nil { + return nil, fmt.Errorf("failed to create refresh request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := o.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("token refresh request failed: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read refresh response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body)) + } + + // log.Debugf("Token response: %s", string(body)) + + var tokenResp tokenResponse + if err = json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to parse token response: %w", err) + } + + // Create token data + return &ClaudeTokenData{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + Email: tokenResp.Account.EmailAddress, + Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339), + }, nil +} + +// CreateTokenStorage creates a new ClaudeTokenStorage from auth bundle and user info. +// This method converts the authentication bundle into a token storage structure +// suitable for persistence and later use. +// +// Parameters: +// - bundle: The authentication bundle containing token data +// +// Returns: +// - *ClaudeTokenStorage: A new token storage instance +func (o *ClaudeAuth) CreateTokenStorage(bundle *ClaudeAuthBundle) *ClaudeTokenStorage { + storage := NewClaudeTokenStorage("") + storage.AccessToken = bundle.TokenData.AccessToken + storage.RefreshToken = bundle.TokenData.RefreshToken + storage.LastRefresh = bundle.LastRefresh + storage.Email = bundle.TokenData.Email + storage.Expire = bundle.TokenData.Expire + + return storage +} + +// RefreshTokensWithRetry refreshes tokens with automatic retry logic. +// This method implements exponential backoff retry logic for token refresh operations, +// providing resilience against temporary network or service issues. +// +// Parameters: +// - ctx: The context for the request +// - refreshToken: The refresh token to use +// - maxRetries: The maximum number of retry attempts +// +// Returns: +// - *ClaudeTokenData: The refreshed token data +// - error: An error if all retry attempts fail +func (o *ClaudeAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*ClaudeTokenData, error) { + var lastErr error + + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + // Wait before retry + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt) * time.Second): + } + } + + tokenData, err := o.RefreshTokens(ctx, refreshToken) + if err == nil { + return tokenData, nil + } + + lastErr = err + log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err) + } + + return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr) +} + +// UpdateTokenStorage updates an existing token storage with new token data. +// This method refreshes the token storage with newly obtained access and refresh tokens, +// updating timestamps and expiration information. +// +// Parameters: +// - storage: The existing token storage to update +// - tokenData: The new token data to apply +func (o *ClaudeAuth) UpdateTokenStorage(storage *ClaudeTokenStorage, tokenData *ClaudeTokenData) { + storage.AccessToken = tokenData.AccessToken + storage.RefreshToken = tokenData.RefreshToken + storage.LastRefresh = time.Now().Format(time.RFC3339) + storage.Email = tokenData.Email + storage.Expire = tokenData.Expire +} diff --git a/internal/auth/claude/token.go b/internal/auth/claude/token.go new file mode 100644 index 0000000000..c5bbf72f55 --- /dev/null +++ b/internal/auth/claude/token.go @@ -0,0 +1,56 @@ +// Package claude provides authentication and token management functionality +// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization, +// and retrieval for maintaining authenticated sessions with the Claude API. +package claude + +import ( + "github.com/KooshaPari/phenotype-go-kit/pkg/auth" + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/misc" +) + +// ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication. +// It extends the shared BaseTokenStorage with Claude-specific functionality, +// maintaining compatibility with the existing auth system. +type ClaudeTokenStorage struct { + *auth.BaseTokenStorage +} + +// NewClaudeTokenStorage creates a new Claude token storage with the given file path. +// +// Parameters: +// - filePath: The full path where the token file should be saved/loaded +// +// Returns: +// - *ClaudeTokenStorage: A new Claude token storage instance +func NewClaudeTokenStorage(filePath string) *ClaudeTokenStorage { + return &ClaudeTokenStorage{ + BaseTokenStorage: auth.NewBaseTokenStorage(filePath), + } +} + +// SaveTokenToFile serializes the Claude token storage to a JSON file. +// This method wraps the base implementation to provide logging compatibility +// with the existing system. +// +// Parameters: +// - authFilePath: The full path where the token file should be saved +// +// Returns: +// - error: An error if the operation fails, nil otherwise +func (ts *ClaudeTokenStorage) SaveTokenToFile(authFilePath string) error { + misc.LogSavingCredentials(authFilePath) + ts.Type = "claude" + + // Create a new token storage with the file path and copy the fields + base := auth.NewBaseTokenStorage(authFilePath) + base.IDToken = ts.IDToken + base.AccessToken = ts.AccessToken + base.RefreshToken = ts.RefreshToken + base.LastRefresh = ts.LastRefresh + base.Email = ts.Email + base.Type = ts.Type + base.Expire = ts.Expire + base.SetMetadata(ts.Metadata) + + return base.Save() +} diff --git a/internal/auth/copilot/copilot_auth.go b/internal/auth/copilot/copilot_auth.go new file mode 100644 index 0000000000..276fa52f91 --- /dev/null +++ b/internal/auth/copilot/copilot_auth.go @@ -0,0 +1,233 @@ +// Package copilot provides authentication and token management for GitHub Copilot API. +// It handles the OAuth2 device flow for secure authentication with the Copilot API. +package copilot + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/util" + log "github.com/sirupsen/logrus" +) + +const ( + // copilotAPITokenURL is the endpoint for getting Copilot API tokens from GitHub token. + copilotAPITokenURL = "https://api.github.com/copilot_internal/v2/token" + // copilotAPIEndpoint is the base URL for making API requests. + copilotAPIEndpoint = "https://api.githubcopilot.com" + + // Common HTTP header values for Copilot API requests. + copilotUserAgent = "GithubCopilot/1.0" + copilotEditorVersion = "vscode/1.100.0" + copilotPluginVersion = "copilot/1.300.0" + copilotIntegrationID = "vscode-chat" + copilotOpenAIIntent = "conversation-panel" +) + +// CopilotAPIToken represents the Copilot API token response. +type CopilotAPIToken struct { + // Token is the JWT token for authenticating with the Copilot API. + Token string `json:"token"` + // ExpiresAt is the Unix timestamp when the token expires. + ExpiresAt int64 `json:"expires_at"` + // Endpoints contains the available API endpoints. + Endpoints struct { + API string `json:"api"` + Proxy string `json:"proxy"` + OriginTracker string `json:"origin-tracker"` + Telemetry string `json:"telemetry"` + } `json:"endpoints,omitempty"` + // ErrorDetails contains error information if the request failed. + ErrorDetails *struct { + URL string `json:"url"` + Message string `json:"message"` + DocumentationURL string `json:"documentation_url"` + } `json:"error_details,omitempty"` +} + +// CopilotAuth handles GitHub Copilot authentication flow. +// It provides methods for device flow authentication and token management. +type CopilotAuth struct { + httpClient *http.Client + deviceClient *DeviceFlowClient + cfg *config.Config +} + +// NewCopilotAuth creates a new CopilotAuth service instance. +// It initializes an HTTP client with proxy settings from the provided configuration. +func NewCopilotAuth(cfg *config.Config) *CopilotAuth { + return &CopilotAuth{ + httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{Timeout: 30 * time.Second}), + deviceClient: NewDeviceFlowClient(cfg), + cfg: cfg, + } +} + +// StartDeviceFlow initiates the device flow authentication. +// Returns the device code response containing the user code and verification URI. +func (c *CopilotAuth) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) { + return c.deviceClient.RequestDeviceCode(ctx) +} + +// WaitForAuthorization polls for user authorization and returns the auth bundle. +func (c *CopilotAuth) WaitForAuthorization(ctx context.Context, deviceCode *DeviceCodeResponse) (*CopilotAuthBundle, error) { + tokenData, err := c.deviceClient.PollForToken(ctx, deviceCode) + if err != nil { + return nil, err + } + + // Fetch the GitHub username + userInfo, err := c.deviceClient.FetchUserInfo(ctx, tokenData.AccessToken) + if err != nil { + log.Warnf("copilot: failed to fetch user info: %v", err) + } + + username := userInfo.Login + if username == "" { + username = "github-user" + } + + return &CopilotAuthBundle{ + TokenData: tokenData, + Username: username, + Email: userInfo.Email, + Name: userInfo.Name, + }, nil +} + +// GetCopilotAPIToken exchanges a GitHub access token for a Copilot API token. +// This token is used to make authenticated requests to the Copilot API. +func (c *CopilotAuth) GetCopilotAPIToken(ctx context.Context, githubAccessToken string) (*CopilotAPIToken, error) { + if githubAccessToken == "" { + return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("github access token is empty")) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, copilotAPITokenURL, nil) + if err != nil { + return nil, NewAuthenticationError(ErrTokenExchangeFailed, err) + } + + req.Header.Set("Authorization", "token "+githubAccessToken) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", copilotUserAgent) + req.Header.Set("Editor-Version", copilotEditorVersion) + req.Header.Set("Editor-Plugin-Version", copilotPluginVersion) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, NewAuthenticationError(ErrTokenExchangeFailed, err) + } + defer func() { + if errClose := resp.Body.Close(); errClose != nil { + log.Errorf("copilot api token: close body error: %v", errClose) + } + }() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, NewAuthenticationError(ErrTokenExchangeFailed, err) + } + + if !isHTTPSuccess(resp.StatusCode) { + return nil, NewAuthenticationError(ErrTokenExchangeFailed, + fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes))) + } + + var apiToken CopilotAPIToken + if err = json.Unmarshal(bodyBytes, &apiToken); err != nil { + return nil, NewAuthenticationError(ErrTokenExchangeFailed, err) + } + + if apiToken.Token == "" { + return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("empty copilot api token")) + } + + return &apiToken, nil +} + +// ValidateToken checks if a GitHub access token is valid by attempting to fetch user info. +func (c *CopilotAuth) ValidateToken(ctx context.Context, accessToken string) (bool, string, error) { + if accessToken == "" { + return false, "", nil + } + + userInfo, err := c.deviceClient.FetchUserInfo(ctx, accessToken) + if err != nil { + return false, "", err + } + + return true, userInfo.Login, nil +} + +// CreateTokenStorage creates a new CopilotTokenStorage from auth bundle. +func (c *CopilotAuth) CreateTokenStorage(bundle *CopilotAuthBundle) *CopilotTokenStorage { + storage := NewCopilotTokenStorage("") + storage.AccessToken = bundle.TokenData.AccessToken + storage.TokenType = bundle.TokenData.TokenType + storage.Scope = bundle.TokenData.Scope + storage.Username = bundle.Username + storage.Email = bundle.Email + storage.Name = bundle.Name + storage.Type = "github-copilot" + return storage +} + +// LoadAndValidateToken loads a token from storage and validates it. +// Returns the storage if valid, or an error if the token is invalid or expired. +func (c *CopilotAuth) LoadAndValidateToken(ctx context.Context, storage *CopilotTokenStorage) (bool, error) { + if storage == nil || storage.AccessToken == "" { + return false, fmt.Errorf("no token available") + } + + // Check if we can still use the GitHub token to get a Copilot API token + apiToken, err := c.GetCopilotAPIToken(ctx, storage.AccessToken) + if err != nil { + return false, err + } + + // Check if the API token is expired + if apiToken.ExpiresAt > 0 && time.Now().Unix() >= apiToken.ExpiresAt { + return false, fmt.Errorf("copilot api token expired") + } + + return true, nil +} + +// GetAPIEndpoint returns the Copilot API endpoint URL. +func (c *CopilotAuth) GetAPIEndpoint() string { + return copilotAPIEndpoint +} + +// MakeAuthenticatedRequest creates an authenticated HTTP request to the Copilot API. +func (c *CopilotAuth) MakeAuthenticatedRequest(ctx context.Context, method, url string, body io.Reader, apiToken *CopilotAPIToken) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+apiToken.Token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", copilotUserAgent) + req.Header.Set("Editor-Version", copilotEditorVersion) + req.Header.Set("Editor-Plugin-Version", copilotPluginVersion) + req.Header.Set("Openai-Intent", copilotOpenAIIntent) + req.Header.Set("Copilot-Integration-Id", copilotIntegrationID) + + return req, nil +} + +// buildChatCompletionURL builds the URL for chat completions API. +func buildChatCompletionURL() string { + return copilotAPIEndpoint + "/chat/completions" +} + +// isHTTPSuccess checks if the status code indicates success (2xx). +func isHTTPSuccess(statusCode int) bool { + return statusCode >= 200 && statusCode < 300 +} diff --git a/internal/auth/copilot/token.go b/internal/auth/copilot/token.go new file mode 100644 index 0000000000..acc8ea936e --- /dev/null +++ b/internal/auth/copilot/token.go @@ -0,0 +1,103 @@ +// Package copilot provides authentication and token management functionality +// for GitHub Copilot AI services. It handles OAuth2 device flow token storage, +// serialization, and retrieval for maintaining authenticated sessions with the Copilot API. +package copilot + +import ( + "github.com/KooshaPari/phenotype-go-kit/pkg/auth" + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/misc" +) + +// CopilotTokenStorage stores OAuth2 token information for GitHub Copilot API authentication. +// It extends the shared BaseTokenStorage with Copilot-specific fields for managing +// GitHub user profile information. +type CopilotTokenStorage struct { + *auth.BaseTokenStorage + + // TokenType is the type of token, typically "bearer". + TokenType string `json:"token_type"` + // Scope is the OAuth2 scope granted to the token. + Scope string `json:"scope"` + // ExpiresAt is the timestamp when the access token expires (if provided). + ExpiresAt string `json:"expires_at,omitempty"` + // Username is the GitHub username associated with this token. + Username string `json:"username"` + // Name is the GitHub display name associated with this token. + Name string `json:"name,omitempty"` +} + +// NewCopilotTokenStorage creates a new Copilot token storage with the given file path. +// +// Parameters: +// - filePath: The full path where the token file should be saved/loaded +// +// Returns: +// - *CopilotTokenStorage: A new Copilot token storage instance +func NewCopilotTokenStorage(filePath string) *CopilotTokenStorage { + return &CopilotTokenStorage{ + BaseTokenStorage: auth.NewBaseTokenStorage(filePath), + } +} + +// CopilotTokenData holds the raw OAuth token response from GitHub. +type CopilotTokenData struct { + // AccessToken is the OAuth2 access token. + AccessToken string `json:"access_token"` + // TokenType is the type of token, typically "bearer". + TokenType string `json:"token_type"` + // Scope is the OAuth2 scope granted to the token. + Scope string `json:"scope"` +} + +// CopilotAuthBundle bundles authentication data for storage. +type CopilotAuthBundle struct { + // TokenData contains the OAuth token information. + TokenData *CopilotTokenData + // Username is the GitHub username. + Username string + // Email is the GitHub email address. + Email string + // Name is the GitHub display name. + Name string +} + +// DeviceCodeResponse represents GitHub's device code response. +type DeviceCodeResponse struct { + // DeviceCode is the device verification code. + DeviceCode string `json:"device_code"` + // UserCode is the code the user must enter at the verification URI. + UserCode string `json:"user_code"` + // VerificationURI is the URL where the user should enter the code. + VerificationURI string `json:"verification_uri"` + // ExpiresIn is the number of seconds until the device code expires. + ExpiresIn int `json:"expires_in"` + // Interval is the minimum number of seconds to wait between polling requests. + Interval int `json:"interval"` +} + +// SaveTokenToFile serializes the Copilot token storage to a JSON file. +// This method wraps the base implementation to provide logging compatibility +// with the existing system. +// +// Parameters: +// - authFilePath: The full path where the token file should be saved +// +// Returns: +// - error: An error if the operation fails, nil otherwise +func (ts *CopilotTokenStorage) SaveTokenToFile(authFilePath string) error { + misc.LogSavingCredentials(authFilePath) + ts.Type = "github-copilot" + + // Create a new token storage with the file path and copy the fields + base := auth.NewBaseTokenStorage(authFilePath) + base.IDToken = ts.IDToken + base.AccessToken = ts.AccessToken + base.RefreshToken = ts.RefreshToken + base.LastRefresh = ts.LastRefresh + base.Email = ts.Email + base.Type = ts.Type + base.Expire = ts.Expire + base.SetMetadata(ts.Metadata) + + return base.Save() +} diff --git a/internal/auth/gemini/gemini_auth.go b/internal/auth/gemini/gemini_auth.go new file mode 100644 index 0000000000..36c97c6c28 --- /dev/null +++ b/internal/auth/gemini/gemini_auth.go @@ -0,0 +1,387 @@ +// Package gemini provides authentication and token management functionality +// for Google's Gemini AI services. It handles OAuth2 authentication flows, +// including obtaining tokens via web-based authorization, storing tokens, +// and refreshing them when they expire. +package gemini + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "time" + + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/auth/codex" + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/browser" + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/misc" + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/util" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" + "golang.org/x/net/proxy" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +// OAuth configuration constants for Gemini +const ( + ClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" + ClientSecret = "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" + DefaultCallbackPort = 8085 +) + +// OAuth scopes for Gemini authentication +var Scopes = []string{ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +} + +// GeminiAuth provides methods for handling the Gemini OAuth2 authentication flow. +// It encapsulates the logic for obtaining, storing, and refreshing authentication tokens +// for Google's Gemini AI services. +type GeminiAuth struct { +} + +// WebLoginOptions customizes the interactive OAuth flow. +type WebLoginOptions struct { + NoBrowser bool + CallbackPort int + Prompt func(string) (string, error) +} + +// NewGeminiAuth creates a new instance of GeminiAuth. +func NewGeminiAuth() *GeminiAuth { + return &GeminiAuth{} +} + +// GetAuthenticatedClient configures and returns an HTTP client ready for making authenticated API calls. +// It manages the entire OAuth2 flow, including handling proxies, loading existing tokens, +// initiating a new web-based OAuth flow if necessary, and refreshing tokens. +// +// Parameters: +// - ctx: The context for the HTTP client +// - ts: The Gemini token storage containing authentication tokens +// - cfg: The configuration containing proxy settings +// - opts: Optional parameters to customize browser and prompt behavior +// +// Returns: +// - *http.Client: An HTTP client configured with authentication +// - error: An error if the client configuration fails, nil otherwise +func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiTokenStorage, cfg *config.Config, opts *WebLoginOptions) (*http.Client, error) { + callbackPort := DefaultCallbackPort + if opts != nil && opts.CallbackPort > 0 { + callbackPort = opts.CallbackPort + } + callbackURL := fmt.Sprintf("http://localhost:%d/oauth2callback", callbackPort) + + // Configure proxy settings for the HTTP client if a proxy URL is provided. + proxyURL, err := url.Parse(cfg.ProxyURL) + if err == nil { + var transport *http.Transport + if proxyURL.Scheme == "socks5" { + // Handle SOCKS5 proxy. + username := proxyURL.User.Username() + password, _ := proxyURL.User.Password() + auth := &proxy.Auth{User: username, Password: password} + dialer, errSOCKS5 := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct) + if errSOCKS5 != nil { + log.Errorf("create SOCKS5 dialer failed: %v", errSOCKS5) + return nil, fmt.Errorf("create SOCKS5 dialer failed: %w", errSOCKS5) + } + transport = &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) + }, + } + } else if proxyURL.Scheme == "http" || proxyURL.Scheme == "https" { + // Handle HTTP/HTTPS proxy. + transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)} + } + + if transport != nil { + proxyClient := &http.Client{Transport: transport} + ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyClient) + } + } + + // Configure the OAuth2 client. + conf := &oauth2.Config{ + ClientID: ClientID, + ClientSecret: ClientSecret, + RedirectURL: callbackURL, // This will be used by the local server. + Scopes: Scopes, + Endpoint: google.Endpoint, + } + + var token *oauth2.Token + + // If no token is found in storage, initiate the web-based OAuth flow. + if ts.Token == nil { + fmt.Printf("Could not load token from file, starting OAuth flow.\n") + token, err = g.getTokenFromWeb(ctx, conf, opts) + if err != nil { + return nil, fmt.Errorf("failed to get token from web: %w", err) + } + // After getting a new token, create a new token storage object with user info. + newTs, errCreateTokenStorage := g.createTokenStorage(ctx, conf, token, ts.ProjectID) + if errCreateTokenStorage != nil { + log.Errorf("Warning: failed to create token storage: %v", errCreateTokenStorage) + return nil, errCreateTokenStorage + } + *ts = *newTs + } + + // Unmarshal the stored token into an oauth2.Token object. + tsToken, _ := json.Marshal(ts.Token) + if err = json.Unmarshal(tsToken, &token); err != nil { + return nil, fmt.Errorf("failed to unmarshal token: %w", err) + } + + // Return an HTTP client that automatically handles token refreshing. + return conf.Client(ctx, token), nil +} + +// createTokenStorage creates a new GeminiTokenStorage object. It fetches the user's email +// using the provided token and populates the storage structure. +// +// Parameters: +// - ctx: The context for the HTTP request +// - config: The OAuth2 configuration +// - token: The OAuth2 token to use for authentication +// - projectID: The Google Cloud Project ID to associate with this token +// +// Returns: +// - *GeminiTokenStorage: A new token storage object with user information +// - error: An error if the token storage creation fails, nil otherwise +func (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth2.Token, projectID string) (*GeminiTokenStorage, error) { + httpClient := config.Client(ctx, token) + req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil) + if err != nil { + return nil, fmt.Errorf("could not get user info: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer func() { + if err = resp.Body.Close(); err != nil { + log.Printf("warn: failed to close response body: %v", err) + } + }() + + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("get user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + emailResult := gjson.GetBytes(bodyBytes, "email") + if emailResult.Exists() && emailResult.Type == gjson.String { + fmt.Printf("Authenticated user email: %s\n", emailResult.String()) + } else { + fmt.Println("Failed to get user email from token") + } + + var ifToken map[string]any + jsonData, _ := json.Marshal(token) + err = json.Unmarshal(jsonData, &ifToken) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal token: %w", err) + } + + ifToken["token_uri"] = "https://oauth2.googleapis.com/token" + ifToken["client_id"] = ClientID + ifToken["client_secret"] = ClientSecret + ifToken["scopes"] = Scopes + ifToken["universe_domain"] = "googleapis.com" + + ts := NewGeminiTokenStorage("") + ts.Token = ifToken + ts.ProjectID = projectID + ts.Email = emailResult.String() + + return ts, nil +} + +// getTokenFromWeb initiates the web-based OAuth2 authorization flow. +// It starts a local HTTP server to listen for the callback from Google's auth server, +// opens the user's browser to the authorization URL, and exchanges the received +// authorization code for an access token. +// +// Parameters: +// - ctx: The context for the HTTP client +// - config: The OAuth2 configuration +// - opts: Optional parameters to customize browser and prompt behavior +// +// Returns: +// - *oauth2.Token: The OAuth2 token obtained from the authorization flow +// - error: An error if the token acquisition fails, nil otherwise +func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config, opts *WebLoginOptions) (*oauth2.Token, error) { + callbackPort := DefaultCallbackPort + if opts != nil && opts.CallbackPort > 0 { + callbackPort = opts.CallbackPort + } + callbackURL := fmt.Sprintf("http://localhost:%d/oauth2callback", callbackPort) + + // Use a channel to pass the authorization code from the HTTP handler to the main function. + codeChan := make(chan string, 1) + errChan := make(chan error, 1) + + // Create a new HTTP server with its own multiplexer. + mux := http.NewServeMux() + server := &http.Server{Addr: fmt.Sprintf(":%d", callbackPort), Handler: mux} + config.RedirectURL = callbackURL + + mux.HandleFunc("/oauth2callback", func(w http.ResponseWriter, r *http.Request) { + if err := r.URL.Query().Get("error"); err != "" { + _, _ = fmt.Fprintf(w, "Authentication failed: %s", err) + select { + case errChan <- fmt.Errorf("authentication failed via callback: %s", err): + default: + } + return + } + code := r.URL.Query().Get("code") + if code == "" { + _, _ = fmt.Fprint(w, "Authentication failed: code not found.") + select { + case errChan <- fmt.Errorf("code not found in callback"): + default: + } + return + } + _, _ = fmt.Fprint(w, "
You can close this window.
") + select { + case codeChan <- code: + default: + } + }) + + // Start the server in a goroutine. + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Errorf("ListenAndServe(): %v", err) + select { + case errChan <- err: + default: + } + } + }() + + // Open the authorization URL in the user's browser. + authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent")) + + noBrowser := false + if opts != nil { + noBrowser = opts.NoBrowser + } + + if !noBrowser { + fmt.Println("Opening browser for authentication...") + + // Check if browser is available + if !browser.IsAvailable() { + log.Warn("No browser available on this system") + util.PrintSSHTunnelInstructions(callbackPort) + fmt.Printf("Please manually open this URL in your browser:\n\n%s\n", authURL) + } else { + if err := browser.OpenURL(authURL); err != nil { + authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err) + log.Warn(codex.GetUserFriendlyMessage(authErr)) + util.PrintSSHTunnelInstructions(callbackPort) + fmt.Printf("Please manually open this URL in your browser:\n\n%s\n", authURL) + + // Log platform info for debugging + platformInfo := browser.GetPlatformInfo() + log.Debugf("Browser platform info: %+v", platformInfo) + } else { + log.Debug("Browser opened successfully") + } + } + } else { + util.PrintSSHTunnelInstructions(callbackPort) + fmt.Printf("Please open this URL in your browser:\n\n%s\n", authURL) + } + + fmt.Println("Waiting for authentication callback...") + + // Wait for the authorization code or an error. + var authCode string + timeoutTimer := time.NewTimer(5 * time.Minute) + defer timeoutTimer.Stop() + + var manualPromptTimer *time.Timer + var manualPromptC <-chan time.Time + if opts != nil && opts.Prompt != nil { + manualPromptTimer = time.NewTimer(15 * time.Second) + manualPromptC = manualPromptTimer.C + defer manualPromptTimer.Stop() + } + +waitForCallback: + for { + select { + case code := <-codeChan: + authCode = code + break waitForCallback + case err := <-errChan: + return nil, err + case <-manualPromptC: + manualPromptC = nil + if manualPromptTimer != nil { + manualPromptTimer.Stop() + } + select { + case code := <-codeChan: + authCode = code + break waitForCallback + case err := <-errChan: + return nil, err + default: + } + input, err := opts.Prompt("Paste the Gemini callback URL (or press Enter to keep waiting): ") + if err != nil { + return nil, err + } + parsed, err := misc.ParseOAuthCallback(input) + if err != nil { + return nil, err + } + if parsed == nil { + continue + } + if parsed.Error != "" { + return nil, fmt.Errorf("authentication failed via callback: %s", parsed.Error) + } + if parsed.Code == "" { + return nil, fmt.Errorf("code not found in callback") + } + authCode = parsed.Code + break waitForCallback + case <-timeoutTimer.C: + return nil, fmt.Errorf("oauth flow timed out") + } + } + + // Shutdown the server. + if err := server.Shutdown(ctx); err != nil { + log.Errorf("Failed to shut down server: %v", err) + } + + // Exchange the authorization code for a token. + token, err := config.Exchange(ctx, authCode) + if err != nil { + return nil, fmt.Errorf("failed to exchange token: %w", err) + } + + fmt.Println("Authentication successful.") + return token, nil +} diff --git a/internal/auth/gemini/gemini_token.go b/internal/auth/gemini/gemini_token.go new file mode 100644 index 0000000000..c4e0f4dd1f --- /dev/null +++ b/internal/auth/gemini/gemini_token.go @@ -0,0 +1,88 @@ +// Package gemini provides authentication and token management functionality +// for Google's Gemini AI services. It handles OAuth2 token storage, serialization, +// and retrieval for maintaining authenticated sessions with the Gemini API. +package gemini + +import ( + "fmt" + "strings" + + "github.com/KooshaPari/phenotype-go-kit/pkg/auth" + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/misc" +) + +// GeminiTokenStorage stores OAuth2 token information for Google Gemini API authentication. +// It extends the shared BaseTokenStorage with Gemini-specific fields for managing +// Google Cloud Project information. +type GeminiTokenStorage struct { + *auth.BaseTokenStorage + + // Token holds the raw OAuth2 token data, including access and refresh tokens. + Token any `json:"token"` + + // ProjectID is the Google Cloud Project ID associated with this token. + ProjectID string `json:"project_id"` + + // Auto indicates if the project ID was automatically selected. + Auto bool `json:"auto"` + + // Checked indicates if the associated Cloud AI API has been verified as enabled. + Checked bool `json:"checked"` +} + +// NewGeminiTokenStorage creates a new Gemini token storage with the given file path. +// +// Parameters: +// - filePath: The full path where the token file should be saved/loaded +// +// Returns: +// - *GeminiTokenStorage: A new Gemini token storage instance +func NewGeminiTokenStorage(filePath string) *GeminiTokenStorage { + return &GeminiTokenStorage{ + BaseTokenStorage: auth.NewBaseTokenStorage(filePath), + } +} + +// SaveTokenToFile serializes the Gemini token storage to a JSON file. +// This method wraps the base implementation to provide logging compatibility +// with the existing system. +// +// Parameters: +// - authFilePath: The full path where the token file should be saved +// +// Returns: +// - error: An error if the operation fails, nil otherwise +func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error { + misc.LogSavingCredentials(authFilePath) + ts.Type = "gemini" + + // Create a new token storage with the file path and copy the fields + base := auth.NewBaseTokenStorage(authFilePath) + base.IDToken = ts.IDToken + base.AccessToken = ts.AccessToken + base.RefreshToken = ts.RefreshToken + base.LastRefresh = ts.LastRefresh + base.Email = ts.Email + base.Type = ts.Type + base.Expire = ts.Expire + base.SetMetadata(ts.Metadata) + + return base.Save() +} + +// CredentialFileName returns the filename used to persist Gemini CLI credentials. +// When projectID represents multiple projects (comma-separated or literal ALL), +// the suffix is normalized to "all" and a "gemini-" prefix is enforced to keep +// web and CLI generated files consistent. +func CredentialFileName(email, projectID string, includeProviderPrefix bool) string { + email = strings.TrimSpace(email) + project := strings.TrimSpace(projectID) + if strings.EqualFold(project, "all") || strings.Contains(project, ",") { + return fmt.Sprintf("gemini-%s-all.json", email) + } + prefix := "" + if includeProviderPrefix { + prefix = "gemini-" + } + return fmt.Sprintf("%s%s-%s.json", prefix, email, project) +}