feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
This commit is contained in:
82
internal/pkg/geminicli/codeassist_types.go
Normal file
82
internal/pkg/geminicli/codeassist_types.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package geminicli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// LoadCodeAssistRequest matches done-hub's internal Code Assist call.
|
||||
type LoadCodeAssistRequest struct {
|
||||
Metadata LoadCodeAssistMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
type LoadCodeAssistMetadata struct {
|
||||
IDEType string `json:"ideType"`
|
||||
Platform string `json:"platform"`
|
||||
PluginType string `json:"pluginType"`
|
||||
}
|
||||
|
||||
type TierInfo struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON supports both legacy string tiers and object tiers.
|
||||
func (t *TierInfo) UnmarshalJSON(data []byte) error {
|
||||
data = bytes.TrimSpace(data)
|
||||
if len(data) == 0 || string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
if data[0] == '"' {
|
||||
var id string
|
||||
if err := json.Unmarshal(data, &id); err != nil {
|
||||
return err
|
||||
}
|
||||
t.ID = id
|
||||
return nil
|
||||
}
|
||||
type alias TierInfo
|
||||
var decoded alias
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
*t = TierInfo(decoded)
|
||||
return nil
|
||||
}
|
||||
|
||||
type LoadCodeAssistResponse struct {
|
||||
CurrentTier *TierInfo `json:"currentTier,omitempty"`
|
||||
PaidTier *TierInfo `json:"paidTier,omitempty"`
|
||||
CloudAICompanionProject string `json:"cloudaicompanionProject,omitempty"`
|
||||
AllowedTiers []AllowedTier `json:"allowedTiers,omitempty"`
|
||||
}
|
||||
|
||||
// GetTier extracts tier ID, prioritizing paidTier over currentTier
|
||||
func (r *LoadCodeAssistResponse) GetTier() string {
|
||||
if r.PaidTier != nil && r.PaidTier.ID != "" {
|
||||
return r.PaidTier.ID
|
||||
}
|
||||
if r.CurrentTier != nil {
|
||||
return r.CurrentTier.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type AllowedTier struct {
|
||||
ID string `json:"id"`
|
||||
IsDefault bool `json:"isDefault,omitempty"`
|
||||
}
|
||||
|
||||
type OnboardUserRequest struct {
|
||||
TierID string `json:"tierId"`
|
||||
Metadata LoadCodeAssistMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
type OnboardUserResponse struct {
|
||||
Done bool `json:"done"`
|
||||
Response *OnboardUserResultData `json:"response,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type OnboardUserResultData struct {
|
||||
CloudAICompanionProject any `json:"cloudaicompanionProject,omitempty"`
|
||||
}
|
||||
51
internal/pkg/geminicli/constants.go
Normal file
51
internal/pkg/geminicli/constants.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Package geminicli provides helpers for interacting with Gemini CLI tools.
|
||||
package geminicli
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
AIStudioBaseURL = "https://generativelanguage.googleapis.com"
|
||||
GeminiCliBaseURL = "https://cloudcode-pa.googleapis.com"
|
||||
|
||||
AuthorizeURL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
TokenURL = "https://oauth2.googleapis.com/token"
|
||||
|
||||
// AIStudioOAuthRedirectURI is the default redirect URI used for AI Studio OAuth.
|
||||
// This matches the "copy/paste callback URL" flow used by OpenAI OAuth in this project.
|
||||
// Note: You still need to register this redirect URI in your Google OAuth client
|
||||
// unless you use an OAuth client type that permits localhost redirect URIs.
|
||||
AIStudioOAuthRedirectURI = "http://localhost:1455/auth/callback"
|
||||
|
||||
// DefaultScopes for Code Assist (includes cloud-platform for API access plus userinfo scopes)
|
||||
// Required by Google's Code Assist API.
|
||||
DefaultCodeAssistScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"
|
||||
|
||||
// DefaultScopes for AI Studio (uses generativelanguage API with OAuth)
|
||||
// Reference: https://ai.google.dev/gemini-api/docs/oauth
|
||||
// For regular Google accounts, supports API calls to generativelanguage.googleapis.com
|
||||
// Note: Google Auth platform currently documents the OAuth scope as
|
||||
// https://www.googleapis.com/auth/generative-language.retriever (often with cloud-platform).
|
||||
DefaultAIStudioScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever"
|
||||
|
||||
// DefaultGoogleOneScopes (DEPRECATED, no longer used)
|
||||
// Google One now always uses the built-in Gemini CLI client with DefaultCodeAssistScopes.
|
||||
// This constant is kept for backward compatibility but is not actively used.
|
||||
DefaultGoogleOneScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"
|
||||
|
||||
// GeminiCLIRedirectURI is the redirect URI used by Gemini CLI for Code Assist OAuth.
|
||||
GeminiCLIRedirectURI = "https://codeassist.google.com/authcode"
|
||||
|
||||
// GeminiCLIOAuthClientID/Secret are the public OAuth client credentials used by Google Gemini CLI.
|
||||
// They enable the "login without creating your own OAuth client" experience, but Google may
|
||||
// restrict which scopes are allowed for this client.
|
||||
GeminiCLIOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
||||
GeminiCLIOAuthClientSecret = "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"
|
||||
|
||||
// GeminiCLIOAuthClientSecretEnv is the environment variable name for the built-in client secret.
|
||||
GeminiCLIOAuthClientSecretEnv = "GEMINI_CLI_OAUTH_CLIENT_SECRET"
|
||||
|
||||
SessionTTL = 30 * time.Minute
|
||||
|
||||
// GeminiCLIUserAgent mimics Gemini CLI to maximize compatibility with internal endpoints.
|
||||
GeminiCLIUserAgent = "GeminiCLI/0.1.5 (Windows; AMD64)"
|
||||
)
|
||||
157
internal/pkg/geminicli/drive_client.go
Normal file
157
internal/pkg/geminicli/drive_client.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package geminicli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/user-management-system/internal/pkg/httpclient"
|
||||
)
|
||||
|
||||
// DriveStorageInfo represents Google Drive storage quota information
|
||||
type DriveStorageInfo struct {
|
||||
Limit int64 `json:"limit"` // Storage limit in bytes
|
||||
Usage int64 `json:"usage"` // Current usage in bytes
|
||||
}
|
||||
|
||||
// DriveClient interface for Google Drive API operations
|
||||
type DriveClient interface {
|
||||
GetStorageQuota(ctx context.Context, accessToken, proxyURL string) (*DriveStorageInfo, error)
|
||||
}
|
||||
|
||||
type driveClient struct{}
|
||||
|
||||
// NewDriveClient creates a new Drive API client
|
||||
func NewDriveClient() DriveClient {
|
||||
return &driveClient{}
|
||||
}
|
||||
|
||||
// GetStorageQuota fetches storage quota from Google Drive API
|
||||
func (c *driveClient) GetStorageQuota(ctx context.Context, accessToken, proxyURL string) (*DriveStorageInfo, error) {
|
||||
const driveAPIURL = "https://www.googleapis.com/drive/v3/about?fields=storageQuota"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", driveAPIURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
// Get HTTP client with proxy support
|
||||
client, err := httpclient.GetClient(httpclient.Options{
|
||||
ProxyURL: proxyURL,
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
|
||||
}
|
||||
|
||||
sleepWithContext := func(d time.Duration) error {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Retry logic with exponential backoff (+ jitter) for rate limits and transient failures
|
||||
var resp *http.Response
|
||||
maxRetries := 3
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("request cancelled: %w", ctx.Err())
|
||||
}
|
||||
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
// Network error retry
|
||||
if attempt < maxRetries-1 {
|
||||
backoff := time.Duration(1<<uint(attempt)) * time.Second
|
||||
jitter := time.Duration(rng.Intn(1000)) * time.Millisecond
|
||||
if err := sleepWithContext(backoff + jitter); err != nil {
|
||||
return nil, fmt.Errorf("request cancelled: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("network error after %d attempts: %w", maxRetries, err)
|
||||
}
|
||||
|
||||
// Success
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
break
|
||||
}
|
||||
|
||||
// Retry 429, 500, 502, 503 with exponential backoff + jitter
|
||||
if (resp.StatusCode == http.StatusTooManyRequests ||
|
||||
resp.StatusCode == http.StatusInternalServerError ||
|
||||
resp.StatusCode == http.StatusBadGateway ||
|
||||
resp.StatusCode == http.StatusServiceUnavailable) && attempt < maxRetries-1 {
|
||||
if err := func() error {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
backoff := time.Duration(1<<uint(attempt)) * time.Second
|
||||
jitter := time.Duration(rng.Intn(1000)) * time.Millisecond
|
||||
return sleepWithContext(backoff + jitter)
|
||||
}(); err != nil {
|
||||
return nil, fmt.Errorf("request cancelled: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("request failed: no response received")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
statusText := http.StatusText(resp.StatusCode)
|
||||
if statusText == "" {
|
||||
statusText = resp.Status
|
||||
}
|
||||
fmt.Printf("[DriveClient] Drive API error: status=%d, msg=%s\n", resp.StatusCode, statusText)
|
||||
// 只返回通用错误
|
||||
return nil, fmt.Errorf("drive API error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// Parse response
|
||||
var result struct {
|
||||
StorageQuota struct {
|
||||
Limit string `json:"limit"` // Can be string or number
|
||||
Usage string `json:"usage"`
|
||||
} `json:"storageQuota"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
// Parse limit and usage (handle both string and number formats)
|
||||
var limit, usage int64
|
||||
if result.StorageQuota.Limit != "" {
|
||||
if val, err := strconv.ParseInt(result.StorageQuota.Limit, 10, 64); err == nil {
|
||||
limit = val
|
||||
}
|
||||
}
|
||||
if result.StorageQuota.Usage != "" {
|
||||
if val, err := strconv.ParseInt(result.StorageQuota.Usage, 10, 64); err == nil {
|
||||
usage = val
|
||||
}
|
||||
}
|
||||
|
||||
return &DriveStorageInfo{
|
||||
Limit: limit,
|
||||
Usage: usage,
|
||||
}, nil
|
||||
}
|
||||
18
internal/pkg/geminicli/drive_client_test.go
Normal file
18
internal/pkg/geminicli/drive_client_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package geminicli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDriveStorageInfo(t *testing.T) {
|
||||
// 测试 DriveStorageInfo 结构体
|
||||
info := &DriveStorageInfo{
|
||||
Limit: 100 * 1024 * 1024 * 1024, // 100GB
|
||||
Usage: 50 * 1024 * 1024 * 1024, // 50GB
|
||||
}
|
||||
|
||||
if info.Limit != 100*1024*1024*1024 {
|
||||
t.Errorf("Expected limit 100GB, got %d", info.Limit)
|
||||
}
|
||||
if info.Usage != 50*1024*1024*1024 {
|
||||
t.Errorf("Expected usage 50GB, got %d", info.Usage)
|
||||
}
|
||||
}
|
||||
25
internal/pkg/geminicli/models.go
Normal file
25
internal/pkg/geminicli/models.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package geminicli
|
||||
|
||||
// Model represents a selectable Gemini model for UI/testing purposes.
|
||||
// Keep JSON fields consistent with existing frontend expectations.
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// DefaultModels is the curated Gemini model list used by the admin UI "test account" flow.
|
||||
var DefaultModels = []Model{
|
||||
{ID: "gemini-2.0-flash", Type: "model", DisplayName: "Gemini 2.0 Flash", CreatedAt: ""},
|
||||
{ID: "gemini-2.5-flash", Type: "model", DisplayName: "Gemini 2.5 Flash", CreatedAt: ""},
|
||||
{ID: "gemini-2.5-flash-image", Type: "model", DisplayName: "Gemini 2.5 Flash Image", CreatedAt: ""},
|
||||
{ID: "gemini-2.5-pro", Type: "model", DisplayName: "Gemini 2.5 Pro", CreatedAt: ""},
|
||||
{ID: "gemini-3-flash-preview", Type: "model", DisplayName: "Gemini 3 Flash Preview", CreatedAt: ""},
|
||||
{ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview", CreatedAt: ""},
|
||||
{ID: "gemini-3.1-pro-preview", Type: "model", DisplayName: "Gemini 3.1 Pro Preview", CreatedAt: ""},
|
||||
{ID: "gemini-3.1-flash-image", Type: "model", DisplayName: "Gemini 3.1 Flash Image", CreatedAt: ""},
|
||||
}
|
||||
|
||||
// DefaultTestModel is the default model to preselect in test flows.
|
||||
const DefaultTestModel = "gemini-2.0-flash"
|
||||
23
internal/pkg/geminicli/models_test.go
Normal file
23
internal/pkg/geminicli/models_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package geminicli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultModels_ContainsImageModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
byID := make(map[string]Model, len(DefaultModels))
|
||||
for _, model := range DefaultModels {
|
||||
byID[model.ID] = model
|
||||
}
|
||||
|
||||
required := []string{
|
||||
"gemini-2.5-flash-image",
|
||||
"gemini-3.1-flash-image",
|
||||
}
|
||||
|
||||
for _, id := range required {
|
||||
if _, ok := byID[id]; !ok {
|
||||
t.Fatalf("expected curated Gemini model %q to exist", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
270
internal/pkg/geminicli/oauth.go
Normal file
270
internal/pkg/geminicli/oauth.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package geminicli
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/user-management-system/internal/pkg/errors"
|
||||
)
|
||||
|
||||
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.
|
||||
// SECURITY: This repo does not embed the built-in client secret; it must be provided via env.
|
||||
if effective.ClientID == "" && effective.ClientSecret == "" {
|
||||
secret := strings.TrimSpace(GeminiCLIOAuthClientSecret)
|
||||
if secret == "" {
|
||||
if v, ok := os.LookupEnv(GeminiCLIOAuthClientSecretEnv); ok {
|
||||
secret = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if secret == "" {
|
||||
return OAuthConfig{}, infraerrors.Newf(http.StatusBadRequest, "GEMINI_CLI_OAUTH_CLIENT_SECRET_MISSING", "built-in Gemini CLI OAuth client_secret is not configured; set %s or provide a custom OAuth client", GeminiCLIOAuthClientSecretEnv)
|
||||
}
|
||||
effective.ClientID = GeminiCLIOAuthClientID
|
||||
effective.ClientSecret = secret
|
||||
} else if effective.ClientID == "" || effective.ClientSecret == "" {
|
||||
return OAuthConfig{}, infraerrors.New(http.StatusBadRequest, "GEMINI_OAUTH_CLIENT_NOT_CONFIGURED", "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
|
||||
|
||||
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
|
||||
}
|
||||
766
internal/pkg/geminicli/oauth_test.go
Normal file
766
internal/pkg/geminicli/oauth_test.go
Normal file
@@ -0,0 +1,766 @@
|
||||
package geminicli
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SessionStore 测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSessionStore_SetAndGet(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
defer store.Stop()
|
||||
|
||||
session := &OAuthSession{
|
||||
State: "test-state",
|
||||
OAuthType: "code_assist",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
store.Set("sid-1", session)
|
||||
|
||||
got, ok := store.Get("sid-1")
|
||||
if !ok {
|
||||
t.Fatal("期望 Get 返回 ok=true,实际返回 false")
|
||||
}
|
||||
if got.State != "test-state" {
|
||||
t.Errorf("期望 State=%q,实际=%q", "test-state", got.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_GetNotFound(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
defer store.Stop()
|
||||
|
||||
_, ok := store.Get("不存在的ID")
|
||||
if ok {
|
||||
t.Error("期望不存在的 sessionID 返回 ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_GetExpired(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
defer store.Stop()
|
||||
|
||||
// 创建一个已过期的 session(CreatedAt 设置为 SessionTTL+1 分钟之前)
|
||||
session := &OAuthSession{
|
||||
State: "expired-state",
|
||||
OAuthType: "code_assist",
|
||||
CreatedAt: time.Now().Add(-(SessionTTL + 1*time.Minute)),
|
||||
}
|
||||
store.Set("expired-sid", session)
|
||||
|
||||
_, ok := store.Get("expired-sid")
|
||||
if ok {
|
||||
t.Error("期望过期的 session 返回 ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_Delete(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
defer store.Stop()
|
||||
|
||||
session := &OAuthSession{
|
||||
State: "to-delete",
|
||||
OAuthType: "code_assist",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
store.Set("del-sid", session)
|
||||
|
||||
// 先确认存在
|
||||
if _, ok := store.Get("del-sid"); !ok {
|
||||
t.Fatal("删除前 session 应该存在")
|
||||
}
|
||||
|
||||
store.Delete("del-sid")
|
||||
|
||||
if _, ok := store.Get("del-sid"); ok {
|
||||
t.Error("删除后 session 不应该存在")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_Stop_Idempotent(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
|
||||
// 多次调用 Stop 不应 panic
|
||||
store.Stop()
|
||||
store.Stop()
|
||||
store.Stop()
|
||||
}
|
||||
|
||||
func TestSessionStore_ConcurrentAccess(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
defer store.Stop()
|
||||
|
||||
const goroutines = 50
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines * 3)
|
||||
|
||||
// 并发写入
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
sid := "concurrent-" + string(rune('A'+idx%26))
|
||||
store.Set(sid, &OAuthSession{
|
||||
State: sid,
|
||||
OAuthType: "code_assist",
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 并发读取
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
sid := "concurrent-" + string(rune('A'+idx%26))
|
||||
store.Get(sid) // 可能找到也可能没找到,关键是不 panic
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 并发删除
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
sid := "concurrent-" + string(rune('A'+idx%26))
|
||||
store.Delete(sid)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GenerateRandomBytes 测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenerateRandomBytes(t *testing.T) {
|
||||
tests := []int{0, 1, 16, 32, 64}
|
||||
for _, n := range tests {
|
||||
b, err := GenerateRandomBytes(n)
|
||||
if err != nil {
|
||||
t.Errorf("GenerateRandomBytes(%d) 出错: %v", n, err)
|
||||
continue
|
||||
}
|
||||
if len(b) != n {
|
||||
t.Errorf("GenerateRandomBytes(%d) 返回长度=%d,期望=%d", n, len(b), n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRandomBytes_Uniqueness(t *testing.T) {
|
||||
// 两次调用应该返回不同的结果(极小概率相同,32字节足够)
|
||||
a, _ := GenerateRandomBytes(32)
|
||||
b, _ := GenerateRandomBytes(32)
|
||||
if string(a) == string(b) {
|
||||
t.Error("两次 GenerateRandomBytes(32) 返回了相同结果,随机性可能有问题")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GenerateState 测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenerateState(t *testing.T) {
|
||||
state, err := GenerateState()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateState() 出错: %v", err)
|
||||
}
|
||||
if state == "" {
|
||||
t.Error("GenerateState() 返回空字符串")
|
||||
}
|
||||
// base64url 编码不应包含 padding '='
|
||||
if strings.Contains(state, "=") {
|
||||
t.Errorf("GenerateState() 结果包含 '=' padding: %s", state)
|
||||
}
|
||||
// base64url 不应包含 '+' 或 '/'
|
||||
if strings.ContainsAny(state, "+/") {
|
||||
t.Errorf("GenerateState() 结果包含非 base64url 字符: %s", state)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GenerateSessionID 测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenerateSessionID(t *testing.T) {
|
||||
sid, err := GenerateSessionID()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSessionID() 出错: %v", err)
|
||||
}
|
||||
// 16 字节 -> 32 个 hex 字符
|
||||
if len(sid) != 32 {
|
||||
t.Errorf("GenerateSessionID() 长度=%d,期望=32", len(sid))
|
||||
}
|
||||
// 必须是合法的 hex 字符串
|
||||
if _, err := hex.DecodeString(sid); err != nil {
|
||||
t.Errorf("GenerateSessionID() 不是合法的 hex 字符串: %s, err=%v", sid, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSessionID_Uniqueness(t *testing.T) {
|
||||
a, _ := GenerateSessionID()
|
||||
b, _ := GenerateSessionID()
|
||||
if a == b {
|
||||
t.Error("两次 GenerateSessionID() 返回了相同结果")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GenerateCodeVerifier 测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenerateCodeVerifier(t *testing.T) {
|
||||
verifier, err := GenerateCodeVerifier()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCodeVerifier() 出错: %v", err)
|
||||
}
|
||||
if verifier == "" {
|
||||
t.Error("GenerateCodeVerifier() 返回空字符串")
|
||||
}
|
||||
// RFC 7636 要求 code_verifier 至少 43 个字符
|
||||
if len(verifier) < 43 {
|
||||
t.Errorf("GenerateCodeVerifier() 长度=%d,RFC 7636 要求至少 43 字符", len(verifier))
|
||||
}
|
||||
// base64url 编码不应包含 padding 和非 URL 安全字符
|
||||
if strings.Contains(verifier, "=") {
|
||||
t.Errorf("GenerateCodeVerifier() 包含 '=' padding: %s", verifier)
|
||||
}
|
||||
if strings.ContainsAny(verifier, "+/") {
|
||||
t.Errorf("GenerateCodeVerifier() 包含非 base64url 字符: %s", verifier)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GenerateCodeChallenge 测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenerateCodeChallenge(t *testing.T) {
|
||||
// 使用已知输入验证输出
|
||||
// RFC 7636 附录 B 示例: verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
// 预期 challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
expected := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
|
||||
challenge := GenerateCodeChallenge(verifier)
|
||||
if challenge != expected {
|
||||
t.Errorf("GenerateCodeChallenge(%q) = %q,期望 %q", verifier, challenge, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCodeChallenge_NoPadding(t *testing.T) {
|
||||
challenge := GenerateCodeChallenge("test-verifier-string")
|
||||
if strings.Contains(challenge, "=") {
|
||||
t.Errorf("GenerateCodeChallenge() 结果包含 '=' padding: %s", challenge)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// base64URLEncode 测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBase64URLEncode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
}{
|
||||
{"空字节", []byte{}},
|
||||
{"单字节", []byte{0xff}},
|
||||
{"多字节", []byte{0x01, 0x02, 0x03, 0x04, 0x05}},
|
||||
{"全零", []byte{0x00, 0x00, 0x00}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := base64URLEncode(tt.input)
|
||||
// 不应包含 '=' padding
|
||||
if strings.Contains(result, "=") {
|
||||
t.Errorf("base64URLEncode(%v) 包含 '=' padding: %s", tt.input, result)
|
||||
}
|
||||
// 不应包含标准 base64 的 '+' 或 '/'
|
||||
if strings.ContainsAny(result, "+/") {
|
||||
t.Errorf("base64URLEncode(%v) 包含非 URL 安全字符: %s", tt.input, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hasRestrictedScope 测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHasRestrictedScope(t *testing.T) {
|
||||
tests := []struct {
|
||||
scope string
|
||||
expected bool
|
||||
}{
|
||||
// 受限 scope
|
||||
{"https://www.googleapis.com/auth/generative-language", true},
|
||||
{"https://www.googleapis.com/auth/generative-language.retriever", true},
|
||||
{"https://www.googleapis.com/auth/generative-language.tuning", true},
|
||||
{"https://www.googleapis.com/auth/drive", true},
|
||||
{"https://www.googleapis.com/auth/drive.readonly", true},
|
||||
{"https://www.googleapis.com/auth/drive.file", true},
|
||||
// 非受限 scope
|
||||
{"https://www.googleapis.com/auth/cloud-platform", false},
|
||||
{"https://www.googleapis.com/auth/userinfo.email", false},
|
||||
{"https://www.googleapis.com/auth/userinfo.profile", false},
|
||||
// 边界情况
|
||||
{"", false},
|
||||
{"random-scope", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.scope, func(t *testing.T) {
|
||||
got := hasRestrictedScope(tt.scope)
|
||||
if got != tt.expected {
|
||||
t.Errorf("hasRestrictedScope(%q) = %v,期望 %v", tt.scope, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BuildAuthorizationURL 测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildAuthorizationURL(t *testing.T) {
|
||||
t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-secret")
|
||||
|
||||
authURL, err := BuildAuthorizationURL(
|
||||
OAuthConfig{},
|
||||
"test-state",
|
||||
"test-challenge",
|
||||
"https://example.com/callback",
|
||||
"",
|
||||
"code_assist",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAuthorizationURL() 出错: %v", err)
|
||||
}
|
||||
|
||||
// 检查返回的 URL 包含期望的参数
|
||||
checks := []string{
|
||||
"response_type=code",
|
||||
"client_id=" + GeminiCLIOAuthClientID,
|
||||
"redirect_uri=",
|
||||
"state=test-state",
|
||||
"code_challenge=test-challenge",
|
||||
"code_challenge_method=S256",
|
||||
"access_type=offline",
|
||||
"prompt=consent",
|
||||
"include_granted_scopes=true",
|
||||
}
|
||||
for _, check := range checks {
|
||||
if !strings.Contains(authURL, check) {
|
||||
t.Errorf("BuildAuthorizationURL() URL 缺少参数 %q\nURL: %s", check, authURL)
|
||||
}
|
||||
}
|
||||
|
||||
// 不应包含 project_id(因为传的是空字符串)
|
||||
if strings.Contains(authURL, "project_id=") {
|
||||
t.Errorf("BuildAuthorizationURL() 空 projectID 时不应包含 project_id 参数")
|
||||
}
|
||||
|
||||
// URL 应该以正确的授权端点开头
|
||||
if !strings.HasPrefix(authURL, AuthorizeURL+"?") {
|
||||
t.Errorf("BuildAuthorizationURL() URL 应以 %s? 开头,实际: %s", AuthorizeURL, authURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURL_EmptyRedirectURI(t *testing.T) {
|
||||
t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-secret")
|
||||
|
||||
_, err := BuildAuthorizationURL(
|
||||
OAuthConfig{},
|
||||
"test-state",
|
||||
"test-challenge",
|
||||
"", // 空 redirectURI
|
||||
"",
|
||||
"code_assist",
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("BuildAuthorizationURL() 空 redirectURI 应该报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "redirect_uri") {
|
||||
t.Errorf("错误消息应包含 'redirect_uri',实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURL_WithProjectID(t *testing.T) {
|
||||
t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-secret")
|
||||
|
||||
authURL, err := BuildAuthorizationURL(
|
||||
OAuthConfig{},
|
||||
"test-state",
|
||||
"test-challenge",
|
||||
"https://example.com/callback",
|
||||
"my-project-123",
|
||||
"code_assist",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAuthorizationURL() 出错: %v", err)
|
||||
}
|
||||
if !strings.Contains(authURL, "project_id=my-project-123") {
|
||||
t.Errorf("BuildAuthorizationURL() 带 projectID 时应包含 project_id 参数\nURL: %s", authURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURL_UsesBuiltinSecretFallback(t *testing.T) {
|
||||
t.Setenv(GeminiCLIOAuthClientSecretEnv, "")
|
||||
|
||||
authURL, err := BuildAuthorizationURL(
|
||||
OAuthConfig{},
|
||||
"test-state",
|
||||
"test-challenge",
|
||||
"https://example.com/callback",
|
||||
"",
|
||||
"code_assist",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAuthorizationURL() 不应报错: %v", err)
|
||||
}
|
||||
if !strings.Contains(authURL, "client_id="+GeminiCLIOAuthClientID) {
|
||||
t.Errorf("应使用内置 Gemini CLI client_id,实际 URL: %s", authURL)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EffectiveOAuthConfig 测试 - 原有测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestEffectiveOAuthConfig_GoogleOne(t *testing.T) {
|
||||
// 内置的 Gemini CLI client secret 不嵌入在此仓库中。
|
||||
// 测试通过环境变量设置一个假的 secret 来模拟运维配置。
|
||||
t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-built-in-secret")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input OAuthConfig
|
||||
oauthType string
|
||||
wantClientID string
|
||||
wantScopes string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Google One 使用内置客户端(空配置)",
|
||||
input: OAuthConfig{},
|
||||
oauthType: "google_one",
|
||||
wantClientID: GeminiCLIOAuthClientID,
|
||||
wantScopes: DefaultCodeAssistScopes,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Google One 使用自定义客户端(传入自定义凭据时使用自定义)",
|
||||
input: OAuthConfig{
|
||||
ClientID: "custom-client-id",
|
||||
ClientSecret: "custom-client-secret",
|
||||
},
|
||||
oauthType: "google_one",
|
||||
wantClientID: "custom-client-id",
|
||||
wantScopes: DefaultCodeAssistScopes,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Google One 内置客户端 + 自定义 scopes(应过滤受限 scopes)",
|
||||
input: OAuthConfig{
|
||||
Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly",
|
||||
},
|
||||
oauthType: "google_one",
|
||||
wantClientID: GeminiCLIOAuthClientID,
|
||||
wantScopes: "https://www.googleapis.com/auth/cloud-platform",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Google One 内置客户端 + 仅受限 scopes(应回退到默认)",
|
||||
input: OAuthConfig{
|
||||
Scopes: "https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly",
|
||||
},
|
||||
oauthType: "google_one",
|
||||
wantClientID: GeminiCLIOAuthClientID,
|
||||
wantScopes: DefaultCodeAssistScopes,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Code Assist 使用内置客户端",
|
||||
input: OAuthConfig{},
|
||||
oauthType: "code_assist",
|
||||
wantClientID: GeminiCLIOAuthClientID,
|
||||
wantScopes: DefaultCodeAssistScopes,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := EffectiveOAuthConfig(tt.input, tt.oauthType)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("EffectiveOAuthConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if got.ClientID != tt.wantClientID {
|
||||
t.Errorf("EffectiveOAuthConfig() ClientID = %v, want %v", got.ClientID, tt.wantClientID)
|
||||
}
|
||||
if got.Scopes != tt.wantScopes {
|
||||
t.Errorf("EffectiveOAuthConfig() Scopes = %v, want %v", got.Scopes, tt.wantScopes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveOAuthConfig_ScopeFiltering(t *testing.T) {
|
||||
t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-built-in-secret")
|
||||
|
||||
// 测试 Google One + 内置客户端过滤受限 scopes
|
||||
cfg, err := EffectiveOAuthConfig(OAuthConfig{
|
||||
Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/userinfo.profile",
|
||||
}, "google_one")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveOAuthConfig() error = %v", err)
|
||||
}
|
||||
|
||||
// 应仅包含 cloud-platform、userinfo.email 和 userinfo.profile
|
||||
// 不应包含 generative-language 或 drive scopes
|
||||
if strings.Contains(cfg.Scopes, "generative-language") {
|
||||
t.Errorf("使用内置客户端时 Scopes 不应包含 generative-language,实际: %v", cfg.Scopes)
|
||||
}
|
||||
if strings.Contains(cfg.Scopes, "drive") {
|
||||
t.Errorf("使用内置客户端时 Scopes 不应包含 drive,实际: %v", cfg.Scopes)
|
||||
}
|
||||
if !strings.Contains(cfg.Scopes, "cloud-platform") {
|
||||
t.Errorf("Scopes 应包含 cloud-platform,实际: %v", cfg.Scopes)
|
||||
}
|
||||
if !strings.Contains(cfg.Scopes, "userinfo.email") {
|
||||
t.Errorf("Scopes 应包含 userinfo.email,实际: %v", cfg.Scopes)
|
||||
}
|
||||
if !strings.Contains(cfg.Scopes, "userinfo.profile") {
|
||||
t.Errorf("Scopes 应包含 userinfo.profile,实际: %v", cfg.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EffectiveOAuthConfig 测试 - 新增分支覆盖
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestEffectiveOAuthConfig_OnlyClientID_NoSecret(t *testing.T) {
|
||||
// 只提供 clientID 不提供 secret 应报错
|
||||
_, err := EffectiveOAuthConfig(OAuthConfig{
|
||||
ClientID: "some-client-id",
|
||||
}, "code_assist")
|
||||
if err == nil {
|
||||
t.Error("只提供 ClientID 不提供 ClientSecret 应该报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "client_id") || !strings.Contains(err.Error(), "client_secret") {
|
||||
t.Errorf("错误消息应提及 client_id 和 client_secret,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveOAuthConfig_OnlyClientSecret_NoID(t *testing.T) {
|
||||
// 只提供 secret 不提供 clientID 应报错
|
||||
_, err := EffectiveOAuthConfig(OAuthConfig{
|
||||
ClientSecret: "some-client-secret",
|
||||
}, "code_assist")
|
||||
if err == nil {
|
||||
t.Error("只提供 ClientSecret 不提供 ClientID 应该报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "client_id") || !strings.Contains(err.Error(), "client_secret") {
|
||||
t.Errorf("错误消息应提及 client_id 和 client_secret,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveOAuthConfig_AIStudio_DefaultScopes_BuiltinClient(t *testing.T) {
|
||||
t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-built-in-secret")
|
||||
|
||||
// ai_studio 类型,使用内置客户端,scopes 为空 -> 应使用 DefaultCodeAssistScopes(因为内置客户端不能请求 generative-language scope)
|
||||
cfg, err := EffectiveOAuthConfig(OAuthConfig{}, "ai_studio")
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveOAuthConfig() error = %v", err)
|
||||
}
|
||||
if cfg.Scopes != DefaultCodeAssistScopes {
|
||||
t.Errorf("ai_studio + 内置客户端应使用 DefaultCodeAssistScopes,实际: %q", cfg.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveOAuthConfig_AIStudio_DefaultScopes_CustomClient(t *testing.T) {
|
||||
// ai_studio 类型,使用自定义客户端,scopes 为空 -> 应使用 DefaultAIStudioScopes
|
||||
cfg, err := EffectiveOAuthConfig(OAuthConfig{
|
||||
ClientID: "custom-id",
|
||||
ClientSecret: "custom-secret",
|
||||
}, "ai_studio")
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveOAuthConfig() error = %v", err)
|
||||
}
|
||||
if cfg.Scopes != DefaultAIStudioScopes {
|
||||
t.Errorf("ai_studio + 自定义客户端应使用 DefaultAIStudioScopes,实际: %q", cfg.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveOAuthConfig_AIStudio_ScopeNormalization(t *testing.T) {
|
||||
// ai_studio 类型,旧的 generative-language scope 应被归一化为 generative-language.retriever
|
||||
cfg, err := EffectiveOAuthConfig(OAuthConfig{
|
||||
ClientID: "custom-id",
|
||||
ClientSecret: "custom-secret",
|
||||
Scopes: "https://www.googleapis.com/auth/generative-language https://www.googleapis.com/auth/cloud-platform",
|
||||
}, "ai_studio")
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveOAuthConfig() error = %v", err)
|
||||
}
|
||||
if strings.Contains(cfg.Scopes, "auth/generative-language ") || strings.HasSuffix(cfg.Scopes, "auth/generative-language") {
|
||||
// 确保不包含未归一化的旧 scope(仅 generative-language 而非 generative-language.retriever)
|
||||
parts := strings.Fields(cfg.Scopes)
|
||||
for _, p := range parts {
|
||||
if p == "https://www.googleapis.com/auth/generative-language" {
|
||||
t.Errorf("ai_studio 应将 generative-language 归一化为 generative-language.retriever,实际 scopes: %q", cfg.Scopes)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(cfg.Scopes, "generative-language.retriever") {
|
||||
t.Errorf("ai_studio 归一化后应包含 generative-language.retriever,实际: %q", cfg.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveOAuthConfig_CommaSeparatedScopes(t *testing.T) {
|
||||
t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-built-in-secret")
|
||||
|
||||
// 逗号分隔的 scopes 应被归一化为空格分隔
|
||||
cfg, err := EffectiveOAuthConfig(OAuthConfig{
|
||||
ClientID: "custom-id",
|
||||
ClientSecret: "custom-secret",
|
||||
Scopes: "https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/userinfo.email",
|
||||
}, "code_assist")
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveOAuthConfig() error = %v", err)
|
||||
}
|
||||
// 应该用空格分隔,而非逗号
|
||||
if strings.Contains(cfg.Scopes, ",") {
|
||||
t.Errorf("逗号分隔的 scopes 应被归一化为空格分隔,实际: %q", cfg.Scopes)
|
||||
}
|
||||
if !strings.Contains(cfg.Scopes, "cloud-platform") {
|
||||
t.Errorf("归一化后应包含 cloud-platform,实际: %q", cfg.Scopes)
|
||||
}
|
||||
if !strings.Contains(cfg.Scopes, "userinfo.email") {
|
||||
t.Errorf("归一化后应包含 userinfo.email,实际: %q", cfg.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveOAuthConfig_MixedCommaAndSpaceScopes(t *testing.T) {
|
||||
// 混合逗号和空格分隔的 scopes
|
||||
cfg, err := EffectiveOAuthConfig(OAuthConfig{
|
||||
ClientID: "custom-id",
|
||||
ClientSecret: "custom-secret",
|
||||
Scopes: "https://www.googleapis.com/auth/cloud-platform, https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile",
|
||||
}, "code_assist")
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveOAuthConfig() error = %v", err)
|
||||
}
|
||||
parts := strings.Fields(cfg.Scopes)
|
||||
if len(parts) != 3 {
|
||||
t.Errorf("归一化后应有 3 个 scope,实际: %d,scopes: %q", len(parts), cfg.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveOAuthConfig_WhitespaceTriming(t *testing.T) {
|
||||
// 输入中的前后空白应被清理
|
||||
cfg, err := EffectiveOAuthConfig(OAuthConfig{
|
||||
ClientID: " custom-id ",
|
||||
ClientSecret: " custom-secret ",
|
||||
Scopes: " https://www.googleapis.com/auth/cloud-platform ",
|
||||
}, "code_assist")
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveOAuthConfig() error = %v", err)
|
||||
}
|
||||
if cfg.ClientID != "custom-id" {
|
||||
t.Errorf("ClientID 应去除前后空白,实际: %q", cfg.ClientID)
|
||||
}
|
||||
if cfg.ClientSecret != "custom-secret" {
|
||||
t.Errorf("ClientSecret 应去除前后空白,实际: %q", cfg.ClientSecret)
|
||||
}
|
||||
if cfg.Scopes != "https://www.googleapis.com/auth/cloud-platform" {
|
||||
t.Errorf("Scopes 应去除前后空白,实际: %q", cfg.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveOAuthConfig_NoEnvSecret(t *testing.T) {
|
||||
t.Setenv(GeminiCLIOAuthClientSecretEnv, "")
|
||||
|
||||
cfg, err := EffectiveOAuthConfig(OAuthConfig{}, "code_assist")
|
||||
if err != nil {
|
||||
t.Fatalf("不设置环境变量时应回退到内置 secret,实际报错: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(cfg.ClientSecret) == "" {
|
||||
t.Error("ClientSecret 不应为空")
|
||||
}
|
||||
if cfg.ClientID != GeminiCLIOAuthClientID {
|
||||
t.Errorf("ClientID 应回退为内置客户端 ID,实际: %q", cfg.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveOAuthConfig_AIStudio_BuiltinClient_CustomScopes(t *testing.T) {
|
||||
t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-built-in-secret")
|
||||
|
||||
// ai_studio + 内置客户端 + 自定义 scopes -> 应过滤受限 scopes
|
||||
cfg, err := EffectiveOAuthConfig(OAuthConfig{
|
||||
Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever",
|
||||
}, "ai_studio")
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveOAuthConfig() error = %v", err)
|
||||
}
|
||||
// 内置客户端应过滤 generative-language.retriever
|
||||
if strings.Contains(cfg.Scopes, "generative-language") {
|
||||
t.Errorf("ai_studio + 内置客户端应过滤受限 scopes,实际: %q", cfg.Scopes)
|
||||
}
|
||||
if !strings.Contains(cfg.Scopes, "cloud-platform") {
|
||||
t.Errorf("应保留 cloud-platform scope,实际: %q", cfg.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveOAuthConfig_UnknownOAuthType_DefaultScopes(t *testing.T) {
|
||||
t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-built-in-secret")
|
||||
|
||||
// 未知的 oauthType 应回退到默认的 code_assist scopes
|
||||
cfg, err := EffectiveOAuthConfig(OAuthConfig{}, "unknown_type")
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveOAuthConfig() error = %v", err)
|
||||
}
|
||||
if cfg.Scopes != DefaultCodeAssistScopes {
|
||||
t.Errorf("未知 oauthType 应使用 DefaultCodeAssistScopes,实际: %q", cfg.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveOAuthConfig_EmptyOAuthType_DefaultScopes(t *testing.T) {
|
||||
t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-built-in-secret")
|
||||
|
||||
// 空的 oauthType 应走 default 分支,使用 DefaultCodeAssistScopes
|
||||
cfg, err := EffectiveOAuthConfig(OAuthConfig{}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveOAuthConfig() error = %v", err)
|
||||
}
|
||||
if cfg.Scopes != DefaultCodeAssistScopes {
|
||||
t.Errorf("空 oauthType 应使用 DefaultCodeAssistScopes,实际: %q", cfg.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveOAuthConfig_CustomClient_NoScopeFiltering(t *testing.T) {
|
||||
// 自定义客户端 + google_one + 包含受限 scopes -> 不应被过滤(因为不是内置客户端)
|
||||
cfg, err := EffectiveOAuthConfig(OAuthConfig{
|
||||
ClientID: "custom-id",
|
||||
ClientSecret: "custom-secret",
|
||||
Scopes: "https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly",
|
||||
}, "google_one")
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveOAuthConfig() error = %v", err)
|
||||
}
|
||||
// 自定义客户端不应过滤任何 scope
|
||||
if !strings.Contains(cfg.Scopes, "generative-language.retriever") {
|
||||
t.Errorf("自定义客户端不应过滤 generative-language.retriever,实际: %q", cfg.Scopes)
|
||||
}
|
||||
if !strings.Contains(cfg.Scopes, "drive.readonly") {
|
||||
t.Errorf("自定义客户端不应过滤 drive.readonly,实际: %q", cfg.Scopes)
|
||||
}
|
||||
}
|
||||
46
internal/pkg/geminicli/sanitize.go
Normal file
46
internal/pkg/geminicli/sanitize.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package geminicli
|
||||
|
||||
import "strings"
|
||||
|
||||
const maxLogBodyLen = 2048
|
||||
|
||||
func SanitizeBodyForLogs(body string) string {
|
||||
body = truncateBase64InMessage(body)
|
||||
if len(body) > maxLogBodyLen {
|
||||
body = body[:maxLogBodyLen] + "...[truncated]"
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func truncateBase64InMessage(message string) string {
|
||||
const maxBase64Length = 50
|
||||
|
||||
result := message
|
||||
offset := 0
|
||||
for {
|
||||
idx := strings.Index(result[offset:], ";base64,")
|
||||
if idx == -1 {
|
||||
break
|
||||
}
|
||||
actualIdx := offset + idx
|
||||
start := actualIdx + len(";base64,")
|
||||
|
||||
end := start
|
||||
for end < len(result) && isBase64Char(result[end]) {
|
||||
end++
|
||||
}
|
||||
|
||||
if end-start > maxBase64Length {
|
||||
result = result[:start+maxBase64Length] + "...[truncated]" + result[end:]
|
||||
offset = start + maxBase64Length + len("...[truncated]")
|
||||
continue
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func isBase64Char(c byte) bool {
|
||||
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/' || c == '='
|
||||
}
|
||||
9
internal/pkg/geminicli/token_types.go
Normal file
9
internal/pkg/geminicli/token_types.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package geminicli
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user