oauth.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. package geminicli
  2. import (
  3. "crypto/rand"
  4. "crypto/sha256"
  5. "encoding/base64"
  6. "encoding/hex"
  7. "fmt"
  8. "net/http"
  9. "net/url"
  10. "os"
  11. "strings"
  12. "sync"
  13. "time"
  14. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  15. )
  16. type OAuthConfig struct {
  17. ClientID string
  18. ClientSecret string
  19. Scopes string
  20. }
  21. type OAuthSession struct {
  22. State string `json:"state"`
  23. CodeVerifier string `json:"code_verifier"`
  24. ProxyURL string `json:"proxy_url,omitempty"`
  25. RedirectURI string `json:"redirect_uri"`
  26. ProjectID string `json:"project_id,omitempty"`
  27. // TierID is a user-selected fallback tier.
  28. // For oauth types that support auto detection (google_one/code_assist), the server will prefer
  29. // the detected tier and fall back to TierID when detection fails.
  30. TierID string `json:"tier_id,omitempty"`
  31. OAuthType string `json:"oauth_type"` // "code_assist" 或 "ai_studio"
  32. CreatedAt time.Time `json:"created_at"`
  33. }
  34. type SessionStore struct {
  35. mu sync.RWMutex
  36. sessions map[string]*OAuthSession
  37. stopCh chan struct{}
  38. }
  39. func NewSessionStore() *SessionStore {
  40. store := &SessionStore{
  41. sessions: make(map[string]*OAuthSession),
  42. stopCh: make(chan struct{}),
  43. }
  44. go store.cleanup()
  45. return store
  46. }
  47. func (s *SessionStore) Set(sessionID string, session *OAuthSession) {
  48. s.mu.Lock()
  49. defer s.mu.Unlock()
  50. s.sessions[sessionID] = session
  51. }
  52. func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
  53. s.mu.RLock()
  54. defer s.mu.RUnlock()
  55. session, ok := s.sessions[sessionID]
  56. if !ok {
  57. return nil, false
  58. }
  59. if time.Since(session.CreatedAt) > SessionTTL {
  60. return nil, false
  61. }
  62. return session, true
  63. }
  64. func (s *SessionStore) Delete(sessionID string) {
  65. s.mu.Lock()
  66. defer s.mu.Unlock()
  67. delete(s.sessions, sessionID)
  68. }
  69. func (s *SessionStore) Stop() {
  70. select {
  71. case <-s.stopCh:
  72. return
  73. default:
  74. close(s.stopCh)
  75. }
  76. }
  77. func (s *SessionStore) cleanup() {
  78. ticker := time.NewTicker(5 * time.Minute)
  79. defer ticker.Stop()
  80. for {
  81. select {
  82. case <-s.stopCh:
  83. return
  84. case <-ticker.C:
  85. s.mu.Lock()
  86. for id, session := range s.sessions {
  87. if time.Since(session.CreatedAt) > SessionTTL {
  88. delete(s.sessions, id)
  89. }
  90. }
  91. s.mu.Unlock()
  92. }
  93. }
  94. }
  95. func GenerateRandomBytes(n int) ([]byte, error) {
  96. b := make([]byte, n)
  97. _, err := rand.Read(b)
  98. if err != nil {
  99. return nil, err
  100. }
  101. return b, nil
  102. }
  103. func GenerateState() (string, error) {
  104. bytes, err := GenerateRandomBytes(32)
  105. if err != nil {
  106. return "", err
  107. }
  108. return base64URLEncode(bytes), nil
  109. }
  110. func GenerateSessionID() (string, error) {
  111. bytes, err := GenerateRandomBytes(16)
  112. if err != nil {
  113. return "", err
  114. }
  115. return hex.EncodeToString(bytes), nil
  116. }
  117. // GenerateCodeVerifier returns an RFC 7636 compatible code verifier (43+ chars).
  118. func GenerateCodeVerifier() (string, error) {
  119. bytes, err := GenerateRandomBytes(32)
  120. if err != nil {
  121. return "", err
  122. }
  123. return base64URLEncode(bytes), nil
  124. }
  125. func GenerateCodeChallenge(verifier string) string {
  126. hash := sha256.Sum256([]byte(verifier))
  127. return base64URLEncode(hash[:])
  128. }
  129. func base64URLEncode(data []byte) string {
  130. return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
  131. }
  132. // EffectiveOAuthConfig returns the effective OAuth configuration.
  133. // oauthType: "code_assist" or "ai_studio" (defaults to "code_assist" if empty).
  134. //
  135. // If ClientID/ClientSecret is not provided, this falls back to the built-in Gemini CLI OAuth client.
  136. //
  137. // Note: The built-in Gemini CLI OAuth client is restricted and may reject some scopes (e.g.
  138. // https://www.googleapis.com/auth/generative-language), which will surface as
  139. // "restricted_client" / "Unregistered scope(s)" errors during browser authorization.
  140. func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error) {
  141. effective := OAuthConfig{
  142. ClientID: strings.TrimSpace(cfg.ClientID),
  143. ClientSecret: strings.TrimSpace(cfg.ClientSecret),
  144. Scopes: strings.TrimSpace(cfg.Scopes),
  145. }
  146. // Normalize scopes: allow comma-separated input but send space-delimited scopes to Google.
  147. if effective.Scopes != "" {
  148. effective.Scopes = strings.Join(strings.Fields(strings.ReplaceAll(effective.Scopes, ",", " ")), " ")
  149. }
  150. // Fall back to built-in Gemini CLI OAuth client when not configured.
  151. // SECURITY: This repo does not embed the built-in client secret; it must be provided via env.
  152. if effective.ClientID == "" && effective.ClientSecret == "" {
  153. secret := strings.TrimSpace(GeminiCLIOAuthClientSecret)
  154. if secret == "" {
  155. if v, ok := os.LookupEnv(GeminiCLIOAuthClientSecretEnv); ok {
  156. secret = strings.TrimSpace(v)
  157. }
  158. }
  159. if secret == "" {
  160. 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)
  161. }
  162. effective.ClientID = GeminiCLIOAuthClientID
  163. effective.ClientSecret = secret
  164. } else if effective.ClientID == "" || effective.ClientSecret == "" {
  165. 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)")
  166. }
  167. isBuiltinClient := effective.ClientID == GeminiCLIOAuthClientID
  168. if effective.Scopes == "" {
  169. // Use different default scopes based on OAuth type
  170. switch oauthType {
  171. case "ai_studio":
  172. // Built-in client can't request some AI Studio scopes (notably generative-language).
  173. if isBuiltinClient {
  174. effective.Scopes = DefaultCodeAssistScopes
  175. } else {
  176. effective.Scopes = DefaultAIStudioScopes
  177. }
  178. case "google_one":
  179. // Google One always uses built-in Gemini CLI client (same as code_assist)
  180. // Built-in client can't request restricted scopes like generative-language.retriever or drive.readonly
  181. effective.Scopes = DefaultCodeAssistScopes
  182. default:
  183. // Default to Code Assist scopes
  184. effective.Scopes = DefaultCodeAssistScopes
  185. }
  186. } else if (oauthType == "ai_studio" || oauthType == "google_one") && isBuiltinClient {
  187. // If user overrides scopes while still using the built-in client, strip restricted scopes.
  188. parts := strings.Fields(effective.Scopes)
  189. filtered := make([]string, 0, len(parts))
  190. for _, s := range parts {
  191. if hasRestrictedScope(s) {
  192. continue
  193. }
  194. filtered = append(filtered, s)
  195. }
  196. if len(filtered) == 0 {
  197. effective.Scopes = DefaultCodeAssistScopes
  198. } else {
  199. effective.Scopes = strings.Join(filtered, " ")
  200. }
  201. }
  202. // Backward compatibility: normalize older AI Studio scope to the currently documented one.
  203. if oauthType == "ai_studio" && effective.Scopes != "" {
  204. parts := strings.Fields(effective.Scopes)
  205. for i := range parts {
  206. if parts[i] == "https://www.googleapis.com/auth/generative-language" {
  207. parts[i] = "https://www.googleapis.com/auth/generative-language.retriever"
  208. }
  209. }
  210. effective.Scopes = strings.Join(parts, " ")
  211. }
  212. return effective, nil
  213. }
  214. func hasRestrictedScope(scope string) bool {
  215. return strings.HasPrefix(scope, "https://www.googleapis.com/auth/generative-language") ||
  216. strings.HasPrefix(scope, "https://www.googleapis.com/auth/drive")
  217. }
  218. func BuildAuthorizationURL(cfg OAuthConfig, state, codeChallenge, redirectURI, projectID, oauthType string) (string, error) {
  219. effectiveCfg, err := EffectiveOAuthConfig(cfg, oauthType)
  220. if err != nil {
  221. return "", err
  222. }
  223. redirectURI = strings.TrimSpace(redirectURI)
  224. if redirectURI == "" {
  225. return "", fmt.Errorf("redirect_uri is required")
  226. }
  227. params := url.Values{}
  228. params.Set("response_type", "code")
  229. params.Set("client_id", effectiveCfg.ClientID)
  230. params.Set("redirect_uri", redirectURI)
  231. params.Set("scope", effectiveCfg.Scopes)
  232. params.Set("state", state)
  233. params.Set("code_challenge", codeChallenge)
  234. params.Set("code_challenge_method", "S256")
  235. params.Set("access_type", "offline")
  236. params.Set("prompt", "consent")
  237. params.Set("include_granted_scopes", "true")
  238. if strings.TrimSpace(projectID) != "" {
  239. params.Set("project_id", strings.TrimSpace(projectID))
  240. }
  241. return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode()), nil
  242. }