| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270 |
- package geminicli
- import (
- "crypto/rand"
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "fmt"
- "net/http"
- "net/url"
- "os"
- "strings"
- "sync"
- "time"
- infraerrors "github.com/Wei-Shaw/sub2api/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
- }
|