package auth import ( "bufio" "context" "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "os" "os/exec" "runtime" "strconv" "strings" "time" ) type OAuthProviderConfig struct { Issuer string ClientID string ClientSecret string // Required for Google OAuth (confidential client) TokenURL string // Override token endpoint (Google uses a different URL than issuer) Scopes string Originator string Port int } func OpenAIOAuthConfig() OAuthProviderConfig { return OAuthProviderConfig{ Issuer: "https://auth.openai.com", ClientID: "app_EMoamEEZ73f0CkXaXp7hrann", Scopes: "openid profile email offline_access", Originator: "codex_cli_rs", Port: 1455, } } // GoogleAntigravityOAuthConfig returns the OAuth configuration for Google Cloud Code Assist (Antigravity). // Client credentials are the same ones used by OpenCode/pi-ai for Cloud Code Assist access. func GoogleAntigravityOAuthConfig() OAuthProviderConfig { // These are the same client credentials used by the OpenCode antigravity plugin. clientID := decodeBase64( "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==", ) clientSecret := decodeBase64("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=") return OAuthProviderConfig{ Issuer: "https://accounts.google.com/o/oauth2/v2", TokenURL: "https://oauth2.googleapis.com/token", ClientID: clientID, ClientSecret: clientSecret, Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs", Port: 51121, } } func decodeBase64(s string) string { data, err := base64.StdEncoding.DecodeString(s) if err != nil { return s } return string(data) } // GenerateState generates a random state string for OAuth CSRF protection. func GenerateState() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { pkce, err := GeneratePKCE() if err != nil { return nil, fmt.Errorf("generating PKCE: %w", err) } state, err := GenerateState() if err != nil { return nil, fmt.Errorf("generating state: %w", err) } redirectURI := fmt.Sprintf("http://localhost:%d/auth/callback", cfg.Port) authURL := buildAuthorizeURL(cfg, pkce, state, redirectURI) resultCh := make(chan callbackResult, 1) mux := http.NewServeMux() mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("state") != state { resultCh <- callbackResult{err: fmt.Errorf("state mismatch")} http.Error(w, "State mismatch", http.StatusBadRequest) return } code := r.URL.Query().Get("code") if code == "" { errMsg := r.URL.Query().Get("error") resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)} http.Error(w, "No authorization code received", http.StatusBadRequest) return } w.Header().Set("Content-Type", "text/html") fmt.Fprint(w, "
You can close this window.
") resultCh <- callbackResult{code: code} }) listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.Port)) if err != nil { return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err) } server := &http.Server{Handler: mux} go server.Serve(listener) defer func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() server.Shutdown(ctx) }() fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL) if err := OpenBrowser(authURL); err != nil { fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL) } fmt.Printf( "Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n", cfg.Port, ) fmt.Println( "please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.", ) fmt.Println("Waiting for authentication (browser or manual paste)...") // Start manual input in a goroutine manualCh := make(chan string) go func() { reader := bufio.NewReader(os.Stdin) input, _ := reader.ReadString('\n') manualCh <- strings.TrimSpace(input) }() select { case result := <-resultCh: if result.err != nil { return nil, result.err } return ExchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI) case manualInput := <-manualCh: if manualInput == "" { return nil, fmt.Errorf("manual input canceled") } // Extract code from URL if it's a full URL code := manualInput if strings.Contains(manualInput, "?") { u, err := url.Parse(manualInput) if err == nil { code = u.Query().Get("code") } } if code == "" { return nil, fmt.Errorf("could not find authorization code in input") } return ExchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI) case <-time.After(5 * time.Minute): return nil, fmt.Errorf("authentication timed out after 5 minutes") } } type callbackResult struct { code string err error } type deviceCodeResponse struct { DeviceAuthID string UserCode string Interval int } // DeviceCodeInfo holds the device code information returned by the OAuth provider. type DeviceCodeInfo struct { DeviceAuthID string `json:"device_auth_id"` UserCode string `json:"user_code"` VerifyURL string `json:"verify_url"` Interval int `json:"interval"` } // RequestDeviceCode requests a device code from the OAuth provider. // Returns the info needed for the user to authenticate in a browser. func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) { reqBody, _ := json.Marshal(map[string]string{ "client_id": cfg.ClientID, }) resp, err := http.Post( cfg.Issuer+"/api/accounts/deviceauth/usercode", "application/json", strings.NewReader(string(reqBody)), ) if err != nil { return nil, fmt.Errorf("requesting device code: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading device code response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("device code request failed: %s", string(body)) } deviceResp, err := parseDeviceCodeResponse(body) if err != nil { return nil, fmt.Errorf("parsing device code response: %w", err) } if deviceResp.Interval < 1 { deviceResp.Interval = 5 } return &DeviceCodeInfo{ DeviceAuthID: deviceResp.DeviceAuthID, UserCode: deviceResp.UserCode, VerifyURL: cfg.Issuer + "/codex/device", Interval: deviceResp.Interval, }, nil } // PollDeviceCodeOnce makes a single poll attempt to check if the user has authenticated. // Returns (credential, nil) on success, (nil, nil) if still pending, or (nil, err) on failure. func PollDeviceCodeOnce(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) { return pollDeviceCode(cfg, deviceAuthID, userCode) } func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) { var raw struct { DeviceAuthID string `json:"device_auth_id"` UserCode string `json:"user_code"` Interval json.RawMessage `json:"interval"` } if err := json.Unmarshal(body, &raw); err != nil { return deviceCodeResponse{}, err } interval, err := parseFlexibleInt(raw.Interval) if err != nil { return deviceCodeResponse{}, err } return deviceCodeResponse{ DeviceAuthID: raw.DeviceAuthID, UserCode: raw.UserCode, Interval: interval, }, nil } func parseFlexibleInt(raw json.RawMessage) (int, error) { if len(raw) == 0 || string(raw) == "null" { return 0, nil } var interval int if err := json.Unmarshal(raw, &interval); err == nil { return interval, nil } var intervalStr string if err := json.Unmarshal(raw, &intervalStr); err == nil { intervalStr = strings.TrimSpace(intervalStr) if intervalStr == "" { return 0, nil } return strconv.Atoi(intervalStr) } return 0, fmt.Errorf("invalid integer value: %s", string(raw)) } func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) { reqBody, _ := json.Marshal(map[string]string{ "client_id": cfg.ClientID, }) resp, err := http.Post( cfg.Issuer+"/api/accounts/deviceauth/usercode", "application/json", strings.NewReader(string(reqBody)), ) if err != nil { return nil, fmt.Errorf("requesting device code: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading device code response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("device code request failed: %s", string(body)) } deviceResp, err := parseDeviceCodeResponse(body) if err != nil { return nil, fmt.Errorf("parsing device code response: %w", err) } if deviceResp.Interval < 1 { deviceResp.Interval = 5 } fmt.Printf( "\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n", cfg.Issuer, deviceResp.UserCode, ) deadline := time.After(15 * time.Minute) ticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second) defer ticker.Stop() for { select { case <-deadline: return nil, fmt.Errorf("device code authentication timed out after 15 minutes") case <-ticker.C: cred, err := pollDeviceCode(cfg, deviceResp.DeviceAuthID, deviceResp.UserCode) if err != nil { continue } if cred != nil { return cred, nil } } } } func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) { reqBody, _ := json.Marshal(map[string]string{ "device_auth_id": deviceAuthID, "user_code": userCode, }) resp, err := http.Post( cfg.Issuer+"/api/accounts/deviceauth/token", "application/json", strings.NewReader(string(reqBody)), ) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("pending") } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading device token response: %w", err) } var tokenResp struct { AuthorizationCode string `json:"authorization_code"` CodeChallenge string `json:"code_challenge"` CodeVerifier string `json:"code_verifier"` } if err := json.Unmarshal(body, &tokenResp); err != nil { return nil, err } redirectURI := cfg.Issuer + "/deviceauth/callback" return ExchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI) } func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) { if cred.RefreshToken == "" { return nil, fmt.Errorf("no refresh token available") } data := url.Values{ "client_id": {cfg.ClientID}, "grant_type": {"refresh_token"}, "refresh_token": {cred.RefreshToken}, "scope": {"openid profile email"}, } if cfg.ClientSecret != "" { data.Set("client_secret", cfg.ClientSecret) } tokenURL := cfg.Issuer + "/oauth/token" if cfg.TokenURL != "" { tokenURL = cfg.TokenURL } resp, err := http.PostForm(tokenURL, data) if err != nil { return nil, fmt.Errorf("refreshing token: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading token refresh response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("token refresh failed: %s", string(body)) } refreshed, err := parseTokenResponse(body, cred.Provider) if err != nil { return nil, err } if refreshed.RefreshToken == "" { refreshed.RefreshToken = cred.RefreshToken } if refreshed.AccountID == "" { refreshed.AccountID = cred.AccountID } if cred.Email != "" && refreshed.Email == "" { refreshed.Email = cred.Email } if cred.ProjectID != "" && refreshed.ProjectID == "" { refreshed.ProjectID = cred.ProjectID } return refreshed, nil } func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string { return buildAuthorizeURL(cfg, pkce, state, redirectURI) } func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string { params := url.Values{ "response_type": {"code"}, "client_id": {cfg.ClientID}, "redirect_uri": {redirectURI}, "scope": {cfg.Scopes}, "code_challenge": {pkce.CodeChallenge}, "code_challenge_method": {"S256"}, "state": {state}, } isGoogle := strings.Contains(strings.ToLower(cfg.Issuer), "accounts.google.com") if isGoogle { // Google OAuth requires these for refresh token support params.Set("access_type", "offline") params.Set("prompt", "consent") } else { // OpenAI-specific parameters params.Set("id_token_add_organizations", "true") params.Set("codex_cli_simplified_flow", "true") if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") { params.Set("originator", "picoclaw") } if cfg.Originator != "" { params.Set("originator", cfg.Originator) } } // Google uses /auth path, OpenAI uses /oauth/authorize if isGoogle { return cfg.Issuer + "/auth?" + params.Encode() } return cfg.Issuer + "/oauth/authorize?" + params.Encode() } // ExchangeCodeForTokens exchanges an authorization code for tokens. func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) { data := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, "client_id": {cfg.ClientID}, "code_verifier": {codeVerifier}, } if cfg.ClientSecret != "" { data.Set("client_secret", cfg.ClientSecret) } tokenURL := cfg.Issuer + "/oauth/token" if cfg.TokenURL != "" { tokenURL = cfg.TokenURL } // Determine provider name from config provider := "openai" if cfg.TokenURL != "" && strings.Contains(cfg.TokenURL, "googleapis.com") { provider = "google-antigravity" } resp, err := http.PostForm(tokenURL, data) if err != nil { return nil, fmt.Errorf("exchanging code for tokens: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading token exchange response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("token exchange failed: %s", string(body)) } return parseTokenResponse(body, provider) } func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) { var tokenResp struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` IDToken string `json:"id_token"` } if err := json.Unmarshal(body, &tokenResp); err != nil { return nil, fmt.Errorf("parsing token response: %w", err) } if tokenResp.AccessToken == "" { return nil, fmt.Errorf("no access token in response") } var expiresAt time.Time if tokenResp.ExpiresIn > 0 { expiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) } cred := &AuthCredential{ AccessToken: tokenResp.AccessToken, RefreshToken: tokenResp.RefreshToken, ExpiresAt: expiresAt, Provider: provider, AuthMethod: "oauth", } if accountID := extractAccountID(tokenResp.IDToken); accountID != "" { cred.AccountID = accountID } else if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" { cred.AccountID = accountID } else if accountID := extractAccountID(tokenResp.IDToken); accountID != "" { // Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims. cred.AccountID = accountID } return cred, nil } func extractAccountID(token string) string { claims, err := parseJWTClaims(token) if err != nil { return "" } if accountID, ok := claims["chatgpt_account_id"].(string); ok && accountID != "" { return accountID } if accountID, ok := claims["https://api.openai.com/auth.chatgpt_account_id"].(string); ok && accountID != "" { return accountID } if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]any); ok { if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" { return accountID } } if orgs, ok := claims["organizations"].([]any); ok { for _, org := range orgs { if orgMap, ok := org.(map[string]any); ok { if accountID, ok := orgMap["id"].(string); ok && accountID != "" { return accountID } } } } return "" } func parseJWTClaims(token string) (map[string]any, error) { parts := strings.Split(token, ".") if len(parts) < 2 { return nil, fmt.Errorf("token is not a JWT") } payload := parts[1] switch len(payload) % 4 { case 2: payload += "==" case 3: payload += "=" } decoded, err := base64URLDecode(payload) if err != nil { return nil, err } var claims map[string]any if err := json.Unmarshal(decoded, &claims); err != nil { return nil, err } return claims, nil } func base64URLDecode(s string) ([]byte, error) { s = strings.NewReplacer("-", "+", "_", "/").Replace(s) return base64.StdEncoding.DecodeString(s) } // OpenBrowser opens the given URL in the user's default browser. func OpenBrowser(url string) error { switch runtime.GOOS { case "darwin": return exec.Command("open", url).Start() case "linux": return exec.Command("xdg-open", url).Start() case "windows": return exec.Command("cmd", "/c", "start", url).Start() default: return fmt.Errorf("unsupported platform: %s", runtime.GOOS) } }