2025-12-25 06:43:00 -08:00
package geminicli
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
2026-02-09 09:58:13 +08:00
"net/http"
2025-12-25 06:43:00 -08:00
"net/url"
2026-02-09 09:58:13 +08:00
"os"
2025-12-25 06:43:00 -08:00
"strings"
"sync"
"time"
2026-02-09 09:58:13 +08:00
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
2025-12-25 06:43:00 -08:00
)
type OAuthConfig struct {
ClientID string
ClientSecret string
Scopes string
}
type OAuthSession struct {
2026-01-04 15:36:00 +08:00
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" `
2025-12-25 06:43:00 -08:00
}
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 ) , "=" )
}
2025-12-25 21:24:22 -08:00
// EffectiveOAuthConfig returns the effective OAuth configuration.
2025-12-25 23:51:11 -08:00
// 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.
2025-12-25 21:24:22 -08:00
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 ) ,
}
2025-12-25 23:51:11 -08:00
// 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 , "," , " " ) ) , " " )
2025-12-25 21:24:22 -08:00
}
2025-12-25 23:51:11 -08:00
// Fall back to built-in Gemini CLI OAuth client when not configured.
2026-02-09 09:58:13 +08:00
// SECURITY: This repo does not embed the built-in client secret; it must be provided via env.
2025-12-25 23:51:11 -08:00
if effective . ClientID == "" && effective . ClientSecret == "" {
2026-02-09 09:58:13 +08:00
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 )
}
2025-12-25 23:51:11 -08:00
effective . ClientID = GeminiCLIOAuthClientID
2026-02-09 09:58:13 +08:00
effective . ClientSecret = secret
2025-12-25 23:51:11 -08:00
} else if effective . ClientID == "" || effective . ClientSecret == "" {
2026-02-09 09:58:13 +08:00
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)" )
2025-12-25 23:51:11 -08:00
}
2026-02-09 09:58:13 +08:00
isBuiltinClient := effective . ClientID == GeminiCLIOAuthClientID
2025-12-25 23:51:11 -08:00
2025-12-25 21:24:22 -08:00
if effective . Scopes == "" {
// Use different default scopes based on OAuth type
2026-01-02 17:47:49 +08:00
switch oauthType {
case "ai_studio" :
2025-12-25 23:51:11 -08:00
// Built-in client can't request some AI Studio scopes (notably generative-language).
if isBuiltinClient {
effective . Scopes = DefaultCodeAssistScopes
} else {
effective . Scopes = DefaultAIStudioScopes
}
2026-01-02 17:47:49 +08:00
case "google_one" :
2026-01-08 23:47:29 +08:00
// 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
2026-01-02 17:47:49 +08:00
default :
2025-12-25 21:24:22 -08:00
// Default to Code Assist scopes
effective . Scopes = DefaultCodeAssistScopes
}
2026-01-03 06:32:04 -08:00
} else if ( oauthType == "ai_studio" || oauthType == "google_one" ) && isBuiltinClient {
2025-12-25 23:51:11 -08:00
// 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 {
2026-01-03 06:32:04 -08:00
if hasRestrictedScope ( s ) {
2025-12-25 23:51:11 -08:00
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 , " " )
2025-12-25 21:24:22 -08:00
}
return effective , nil
}
2026-01-03 06:32:04 -08:00
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" )
}
2025-12-25 21:24:22 -08:00
func BuildAuthorizationURL ( cfg OAuthConfig , state , codeChallenge , redirectURI , projectID , oauthType string ) ( string , error ) {
effectiveCfg , err := EffectiveOAuthConfig ( cfg , oauthType )
if err != nil {
return "" , err
2025-12-25 06:43:00 -08:00
}
redirectURI = strings . TrimSpace ( redirectURI )
if redirectURI == "" {
return "" , fmt . Errorf ( "redirect_uri is required" )
}
params := url . Values { }
params . Set ( "response_type" , "code" )
2025-12-25 21:24:22 -08:00
params . Set ( "client_id" , effectiveCfg . ClientID )
2025-12-25 06:43:00 -08:00
params . Set ( "redirect_uri" , redirectURI )
2025-12-25 21:24:22 -08:00
params . Set ( "scope" , effectiveCfg . Scopes )
2025-12-25 06:43:00 -08:00
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" )
2025-12-25 21:24:22 -08:00
if strings . TrimSpace ( projectID ) != "" {
params . Set ( "project_id" , strings . TrimSpace ( projectID ) )
}
2025-12-25 06:43:00 -08:00
return fmt . Sprintf ( "%s?%s" , AuthorizeURL , params . Encode ( ) ) , nil
}