mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 04:40:52 +08:00
feat(gemini-cli): add Google One login and improve auto-discovery
Add Google One personal account login to Gemini CLI OAuth flow: - CLI --login shows mode menu (Code Assist vs Google One) - Web management API accepts project_id=GOOGLE_ONE sentinel - Auto-discover project via onboardUser without cloudaicompanionProject when project is unresolved Improve robustness of auto-discovery and token handling: - Add context-aware auto-discovery polling (30s timeout, 2s interval) - Distinguish network errors from project-selection-required errors - Refresh expired access tokens in readAuthFile before project lookup - Extend project_id auto-fill to gemini auth type (was antigravity-only) Unify credential file naming to geminicli- prefix for both CLI and web. Add extractAccessToken unit tests (9 cases).
This commit is contained in:
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -186,15 +188,21 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth,
|
||||
if provider == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
if provider == "antigravity" {
|
||||
if provider == "antigravity" || provider == "gemini" {
|
||||
projectID := ""
|
||||
if pid, ok := metadata["project_id"].(string); ok {
|
||||
projectID = strings.TrimSpace(pid)
|
||||
}
|
||||
if projectID == "" {
|
||||
accessToken := ""
|
||||
if token, ok := metadata["access_token"].(string); ok {
|
||||
accessToken = strings.TrimSpace(token)
|
||||
accessToken := extractAccessToken(metadata)
|
||||
// For gemini type, the stored access_token is likely expired (~1h lifetime).
|
||||
// Refresh it using the long-lived refresh_token before querying.
|
||||
if provider == "gemini" {
|
||||
if tokenMap, ok := metadata["token"].(map[string]any); ok {
|
||||
if refreshed, errRefresh := refreshGeminiAccessToken(tokenMap, http.DefaultClient); errRefresh == nil {
|
||||
accessToken = refreshed
|
||||
}
|
||||
}
|
||||
}
|
||||
if accessToken != "" {
|
||||
fetchedProjectID, errFetch := FetchAntigravityProjectID(context.Background(), accessToken, http.DefaultClient)
|
||||
@@ -304,6 +312,67 @@ func (s *FileTokenStore) baseDirSnapshot() string {
|
||||
return s.baseDir
|
||||
}
|
||||
|
||||
func extractAccessToken(metadata map[string]any) string {
|
||||
if at, ok := metadata["access_token"].(string); ok {
|
||||
if v := strings.TrimSpace(at); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if tokenMap, ok := metadata["token"].(map[string]any); ok {
|
||||
if at, ok := tokenMap["access_token"].(string); ok {
|
||||
if v := strings.TrimSpace(at); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func refreshGeminiAccessToken(tokenMap map[string]any, httpClient *http.Client) (string, error) {
|
||||
refreshToken, _ := tokenMap["refresh_token"].(string)
|
||||
clientID, _ := tokenMap["client_id"].(string)
|
||||
clientSecret, _ := tokenMap["client_secret"].(string)
|
||||
tokenURI, _ := tokenMap["token_uri"].(string)
|
||||
|
||||
if refreshToken == "" || clientID == "" || clientSecret == "" {
|
||||
return "", fmt.Errorf("missing refresh credentials")
|
||||
}
|
||||
if tokenURI == "" {
|
||||
tokenURI = "https://oauth2.googleapis.com/token"
|
||||
}
|
||||
|
||||
data := url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {refreshToken},
|
||||
"client_id": {clientID},
|
||||
"client_secret": {clientSecret},
|
||||
}
|
||||
|
||||
resp, err := httpClient.PostForm(tokenURI, data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("refresh request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("refresh failed: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if errUnmarshal := json.Unmarshal(body, &result); errUnmarshal != nil {
|
||||
return "", fmt.Errorf("decode refresh response: %w", errUnmarshal)
|
||||
}
|
||||
|
||||
newAccessToken, _ := result["access_token"].(string)
|
||||
if newAccessToken == "" {
|
||||
return "", fmt.Errorf("no access_token in refresh response")
|
||||
}
|
||||
|
||||
tokenMap["access_token"] = newAccessToken
|
||||
return newAccessToken, nil
|
||||
}
|
||||
|
||||
// jsonEqual compares two JSON blobs by parsing them into Go objects and deep comparing.
|
||||
func jsonEqual(a, b []byte) bool {
|
||||
var objA any
|
||||
|
||||
Reference in New Issue
Block a user