Files
sub2api/backend/internal/pkg/geminicli/oauth.go
IanShaw afcfbb458d fix(gemini): Google One 强制使用内置 OAuth client + 自动获取 project_id + UI 优化 (#212)
* fix(gemini): Google One 强制使用内置 OAuth client + 自动获取 project_id + UI 优化

## 后端改动

### 1. Google One 强制使用内置 Gemini CLI OAuth Client
**问题**:
- Google One 之前允许使用自定义 OAuth client,导致认证流程不稳定
- 与 Code Assist 的行为不一致

**解决方案**:
- 修改 `gemini_oauth_service.go`: Google One 现在与 Code Assist 一样强制使用内置 client (L122-135)
- 更新 `gemini_oauth_client.go`: ExchangeCode 和 RefreshToken 方法支持强制内置 client (L31-44, L77-86)
- 简化 `geminicli/oauth.go`: Google One scope 选择逻辑 (L187-190)
- 标记 `geminicli/constants.go`: DefaultGoogleOneScopes 为 DEPRECATED (L30-33)
- 更新测试用例以反映新行为

**OAuth 类型对比**:
| OAuth类型 | Client来源 | Scopes | Redirect URI |
|-----------|-----------|--------|-----------------|
| code_assist | 内置 Gemini CLI | DefaultCodeAssistScopes | https://codeassist.google.com/authcode |
| google_one | 内置 Gemini CLI (新) | DefaultCodeAssistScopes | https://codeassist.google.com/authcode |
| ai_studio | 必须自定义 | DefaultAIStudioScopes | http://localhost:1455/auth/callback |

### 2. Google One 自动获取 project_id
**问题**:
- Google One 个人账号测试模型时返回 403/404 错误
- 原因:cloudaicompanion API 需要 project_id,但个人账号无需手动创建 GCP 项目

**解决方案**:
- 修改 `gemini_oauth_service.go`: OAuth 流程中自动调用 fetchProjectID
- Google 通过 LoadCodeAssist API 自动分配 project_id
- 与 Gemini CLI 行为保持一致
- 后端根据 project_id 自动选择正确的 API 端点

**影响**:
- Google One 账号现在可以正常使用(需要重新授权)
- Code Assist 和 AI Studio 账号不受影响

### 3. 修复 Gemini 测试账号无内容输出问题
**问题**:
- 测试 Gemini 账号时只显示"测试成功",没有显示 AI 响应内容
- 原因:processGeminiStream 在检查到 finishReason 时立即返回,跳过了内容提取

**解决方案**:
- 修改 `account_test_service.go`: 调整逻辑顺序,先提取内容再检查是否完成
- 确保最后一个 chunk 的内容也能被正确显示

**影响**:
- 所有 Gemini 账号类型(API Key、OAuth)的测试现在都会显示完整响应内容
- 用户可以看到流式输出效果

## 前端改动

### 1. 修复图标宽度压缩问题
**问题**:
- 账户类型选择按钮中的图标在某些情况下会被压缩变形

**解决方案**:
- 修改 `CreateAccountModal.vue`: 为所有平台图标容器添加 `shrink-0` 类
- 确保 Anthropic、OpenAI、Gemini、Antigravity 图标保持固定 8×8 尺寸 (32px × 32px)

### 2. 优化重新授权界面
**问题**:
- 重新授权时显示三个可点击的授权类型选择按钮,可能导致用户误切换到不兼容的授权方式

**解决方案**:
- 修改 `ReAuthAccountModal.vue` (admin 和普通用户版本):
  - 将可点击的授权类型选择按钮改为只读信息展示框
  - 根据账号的 `credentials.oauth_type` 动态显示对应图标和文本
  - 删除 `geminiAIStudioOAuthEnabled` 状态和 `handleSelectGeminiOAuthType` 方法
  - 防止用户误操作

## 测试验证
-  所有后端单元测试通过
-  OAuth client 选择逻辑正确
-  Google One 和 Code Assist 行为一致
-  测试账号显示完整响应内容
-  UI 图标显示正常
-  重新授权界面只读展示正确

* fix(lint): 修复 golangci-lint 错误信息格式问题

- 将错误信息改为小写开头以符合 Go 代码规范
- 修复 ST1005: error strings should not be capitalized
2026-01-08 23:47:29 +08:00

258 lines
7.3 KiB
Go

package geminicli
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"net/url"
"strings"
"sync"
"time"
)
type OAuthConfig struct {
ClientID string
ClientSecret string
Scopes string
}
type OAuthSession struct {
State string `json:"state"`
CodeVerifier string `json:"code_verifier"`
ProxyURL string `json:"proxy_url,omitempty"`
RedirectURI string `json:"redirect_uri"`
ProjectID string `json:"project_id,omitempty"`
// TierID is a user-selected fallback tier.
// For oauth types that support auto detection (google_one/code_assist), the server will prefer
// the detected tier and fall back to TierID when detection fails.
TierID string `json:"tier_id,omitempty"`
OAuthType string `json:"oauth_type"` // "code_assist" 或 "ai_studio"
CreatedAt time.Time `json:"created_at"`
}
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*OAuthSession
stopCh chan struct{}
}
func NewSessionStore() *SessionStore {
store := &SessionStore{
sessions: make(map[string]*OAuthSession),
stopCh: make(chan struct{}),
}
go store.cleanup()
return store
}
func (s *SessionStore) Set(sessionID string, session *OAuthSession) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[sessionID] = session
}
func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[sessionID]
if !ok {
return nil, false
}
if time.Since(session.CreatedAt) > SessionTTL {
return nil, false
}
return session, true
}
func (s *SessionStore) Delete(sessionID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, sessionID)
}
func (s *SessionStore) Stop() {
select {
case <-s.stopCh:
return
default:
close(s.stopCh)
}
}
func (s *SessionStore) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
s.mu.Lock()
for id, session := range s.sessions {
if time.Since(session.CreatedAt) > SessionTTL {
delete(s.sessions, id)
}
}
s.mu.Unlock()
}
}
}
func GenerateRandomBytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
return nil, err
}
return b, nil
}
func GenerateState() (string, error) {
bytes, err := GenerateRandomBytes(32)
if err != nil {
return "", err
}
return base64URLEncode(bytes), nil
}
func GenerateSessionID() (string, error) {
bytes, err := GenerateRandomBytes(16)
if err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
// GenerateCodeVerifier returns an RFC 7636 compatible code verifier (43+ chars).
func GenerateCodeVerifier() (string, error) {
bytes, err := GenerateRandomBytes(32)
if err != nil {
return "", err
}
return base64URLEncode(bytes), nil
}
func GenerateCodeChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
return base64URLEncode(hash[:])
}
func base64URLEncode(data []byte) string {
return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
}
// EffectiveOAuthConfig returns the effective OAuth configuration.
// oauthType: "code_assist" or "ai_studio" (defaults to "code_assist" if empty).
//
// If ClientID/ClientSecret is not provided, this falls back to the built-in Gemini CLI OAuth client.
//
// Note: The built-in Gemini CLI OAuth client is restricted and may reject some scopes (e.g.
// https://www.googleapis.com/auth/generative-language), which will surface as
// "restricted_client" / "Unregistered scope(s)" errors during browser authorization.
func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error) {
effective := OAuthConfig{
ClientID: strings.TrimSpace(cfg.ClientID),
ClientSecret: strings.TrimSpace(cfg.ClientSecret),
Scopes: strings.TrimSpace(cfg.Scopes),
}
// Normalize scopes: allow comma-separated input but send space-delimited scopes to Google.
if effective.Scopes != "" {
effective.Scopes = strings.Join(strings.Fields(strings.ReplaceAll(effective.Scopes, ",", " ")), " ")
}
// Fall back to built-in Gemini CLI OAuth client when not configured.
if effective.ClientID == "" && effective.ClientSecret == "" {
effective.ClientID = GeminiCLIOAuthClientID
effective.ClientSecret = GeminiCLIOAuthClientSecret
} else if effective.ClientID == "" || effective.ClientSecret == "" {
return OAuthConfig{}, fmt.Errorf("OAuth client not configured: please set both client_id and client_secret (or leave both empty to use the built-in Gemini CLI client)")
}
isBuiltinClient := effective.ClientID == GeminiCLIOAuthClientID &&
effective.ClientSecret == GeminiCLIOAuthClientSecret
if effective.Scopes == "" {
// Use different default scopes based on OAuth type
switch oauthType {
case "ai_studio":
// Built-in client can't request some AI Studio scopes (notably generative-language).
if isBuiltinClient {
effective.Scopes = DefaultCodeAssistScopes
} else {
effective.Scopes = DefaultAIStudioScopes
}
case "google_one":
// Google One always uses built-in Gemini CLI client (same as code_assist)
// Built-in client can't request restricted scopes like generative-language.retriever or drive.readonly
effective.Scopes = DefaultCodeAssistScopes
default:
// Default to Code Assist scopes
effective.Scopes = DefaultCodeAssistScopes
}
} else if (oauthType == "ai_studio" || oauthType == "google_one") && isBuiltinClient {
// If user overrides scopes while still using the built-in client, strip restricted scopes.
parts := strings.Fields(effective.Scopes)
filtered := make([]string, 0, len(parts))
for _, s := range parts {
if hasRestrictedScope(s) {
continue
}
filtered = append(filtered, s)
}
if len(filtered) == 0 {
effective.Scopes = DefaultCodeAssistScopes
} else {
effective.Scopes = strings.Join(filtered, " ")
}
}
// Backward compatibility: normalize older AI Studio scope to the currently documented one.
if oauthType == "ai_studio" && effective.Scopes != "" {
parts := strings.Fields(effective.Scopes)
for i := range parts {
if parts[i] == "https://www.googleapis.com/auth/generative-language" {
parts[i] = "https://www.googleapis.com/auth/generative-language.retriever"
}
}
effective.Scopes = strings.Join(parts, " ")
}
return effective, nil
}
func hasRestrictedScope(scope string) bool {
return strings.HasPrefix(scope, "https://www.googleapis.com/auth/generative-language") ||
strings.HasPrefix(scope, "https://www.googleapis.com/auth/drive")
}
func BuildAuthorizationURL(cfg OAuthConfig, state, codeChallenge, redirectURI, projectID, oauthType string) (string, error) {
effectiveCfg, err := EffectiveOAuthConfig(cfg, oauthType)
if err != nil {
return "", err
}
redirectURI = strings.TrimSpace(redirectURI)
if redirectURI == "" {
return "", fmt.Errorf("redirect_uri is required")
}
params := url.Values{}
params.Set("response_type", "code")
params.Set("client_id", effectiveCfg.ClientID)
params.Set("redirect_uri", redirectURI)
params.Set("scope", effectiveCfg.Scopes)
params.Set("state", state)
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
params.Set("access_type", "offline")
params.Set("prompt", "consent")
params.Set("include_granted_scopes", "true")
if strings.TrimSpace(projectID) != "" {
params.Set("project_id", strings.TrimSpace(projectID))
}
return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode()), nil
}