Spaces:
Paused
feat(auth): ✨ add configurable OAuth callback ports for all providers
Browse filesIntroduce environment variable configuration for OAuth callback server ports across Gemini CLI, Antigravity, and iFlow providers to prevent port conflicts and support multi-instance deployments.
- Add `*_OAUTH_PORT` environment variables (GEMINI_CLI_OAUTH_PORT, ANTIGRAVITY_OAUTH_PORT, IFLOW_OAUTH_PORT)
- Implement dynamic port resolution with fallback to hardcoded defaults
- Add comprehensive documentation section explaining port configuration methods
- Integrate port settings into Settings Tool UI for easy configuration
- Update provider implementations to use configurable ports via property/function
- Optimize launcher TUI startup by deferring heavy provider imports to Settings Tool
- Add validation and warning logging for invalid port values
Configuration can be managed via TUI settings menu or `.env` file. Port changes take effect on next authentication attempt without affecting existing tokens.
|
@@ -856,6 +856,42 @@ class AntigravityAuthBase(GoogleOAuthBase):
|
|
| 856 |
- Headless environment detection
|
| 857 |
- Sequential refresh queue processing
|
| 858 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 859 |
---
|
| 860 |
|
| 861 |
|
|
@@ -877,8 +913,8 @@ The `GeminiCliProvider` is the most complex implementation, mimicking the Google
|
|
| 877 |
|
| 878 |
#### Authentication (`gemini_auth_base.py`)
|
| 879 |
|
| 880 |
-
* **Device Flow**: Uses a standard OAuth 2.0 flow. The `credential_tool` spins up a local web server (`localhost:8085`) to capture the callback from Google's auth page.
|
| 881 |
-
* **Token Lifecycle**:
|
| 882 |
* **Proactive Refresh**: Tokens are refreshed 5 minutes before expiry.
|
| 883 |
* **Atomic Writes**: Credential files are updated using a temp-file-and-move strategy to prevent corruption during writes.
|
| 884 |
* **Revocation Handling**: If a `400` or `401` occurs during refresh, the token is marked as revoked, preventing infinite retry loops.
|
|
@@ -907,7 +943,7 @@ The provider employs a sophisticated, cached discovery mechanism to find a valid
|
|
| 907 |
### 3.3. iFlow (`iflow_provider.py`)
|
| 908 |
|
| 909 |
* **Hybrid Auth**: Uses a custom OAuth flow (Authorization Code) to obtain an `access_token`. However, the *actual* API calls use a separate `apiKey` that is retrieved from the user's profile (`/api/oauth/getUserInfo`) using the access token.
|
| 910 |
-
* **Callback Server**: The auth flow spins up a local server
|
| 911 |
* **Token Management**: Automatically refreshes the OAuth token and re-fetches the API key if needed.
|
| 912 |
* **Schema Cleaning**: Similar to Qwen, it aggressively sanitizes tool schemas to prevent 400 errors.
|
| 913 |
* **Dedicated Logging**: Implements `_IFlowFileLogger` to capture raw chunks for debugging proprietary API behaviors.
|
|
|
|
| 856 |
- Headless environment detection
|
| 857 |
- Sequential refresh queue processing
|
| 858 |
|
| 859 |
+
#### OAuth Callback Port Configuration
|
| 860 |
+
|
| 861 |
+
Each OAuth provider uses a local callback server during authentication. The callback port can be customized via environment variables to avoid conflicts with other services.
|
| 862 |
+
|
| 863 |
+
**Default Ports:**
|
| 864 |
+
|
| 865 |
+
| Provider | Default Port | Environment Variable |
|
| 866 |
+
|----------|-------------|---------------------|
|
| 867 |
+
| Gemini CLI | 8085 | `GEMINI_CLI_OAUTH_PORT` |
|
| 868 |
+
| Antigravity | 51121 | `ANTIGRAVITY_OAUTH_PORT` |
|
| 869 |
+
| iFlow | 11451 | `IFLOW_OAUTH_PORT` |
|
| 870 |
+
|
| 871 |
+
**Configuration Methods:**
|
| 872 |
+
|
| 873 |
+
1. **Via TUI Settings Menu:**
|
| 874 |
+
- Main Menu → `4. View Provider & Advanced Settings` → `1. Launch Settings Tool`
|
| 875 |
+
- Select the provider (Gemini CLI, Antigravity, or iFlow)
|
| 876 |
+
- Modify the `*_OAUTH_PORT` setting
|
| 877 |
+
- Use "Reset to Default" to restore the original port
|
| 878 |
+
|
| 879 |
+
2. **Via `.env` file:**
|
| 880 |
+
```env
|
| 881 |
+
# Custom OAuth callback ports (optional)
|
| 882 |
+
GEMINI_CLI_OAUTH_PORT=8085
|
| 883 |
+
ANTIGRAVITY_OAUTH_PORT=51121
|
| 884 |
+
IFLOW_OAUTH_PORT=11451
|
| 885 |
+
```
|
| 886 |
+
|
| 887 |
+
**When to Change Ports:**
|
| 888 |
+
|
| 889 |
+
- If the default port conflicts with another service on your system
|
| 890 |
+
- If running multiple proxy instances on the same machine
|
| 891 |
+
- If firewall rules require specific port ranges
|
| 892 |
+
|
| 893 |
+
**Note:** Port changes take effect on the next OAuth authentication attempt. Existing tokens are not affected.
|
| 894 |
+
|
| 895 |
---
|
| 896 |
|
| 897 |
|
|
|
|
| 913 |
|
| 914 |
#### Authentication (`gemini_auth_base.py`)
|
| 915 |
|
| 916 |
+
* **Device Flow**: Uses a standard OAuth 2.0 flow. The `credential_tool` spins up a local web server (default: `localhost:8085`, configurable via `GEMINI_CLI_OAUTH_PORT`) to capture the callback from Google's auth page.
|
| 917 |
+
* **Token Lifecycle**:
|
| 918 |
* **Proactive Refresh**: Tokens are refreshed 5 minutes before expiry.
|
| 919 |
* **Atomic Writes**: Credential files are updated using a temp-file-and-move strategy to prevent corruption during writes.
|
| 920 |
* **Revocation Handling**: If a `400` or `401` occurs during refresh, the token is marked as revoked, preventing infinite retry loops.
|
|
|
|
| 943 |
### 3.3. iFlow (`iflow_provider.py`)
|
| 944 |
|
| 945 |
* **Hybrid Auth**: Uses a custom OAuth flow (Authorization Code) to obtain an `access_token`. However, the *actual* API calls use a separate `apiKey` that is retrieved from the user's profile (`/api/oauth/getUserInfo`) using the access token.
|
| 946 |
+
* **Callback Server**: The auth flow spins up a local server (default: port `11451`, configurable via `IFLOW_OAUTH_PORT`) to capture the redirect.
|
| 947 |
* **Token Management**: Automatically refreshes the OAuth token and re-fetches the API key if needed.
|
| 948 |
* **Schema Cleaning**: Similar to Qwen, it aggressively sanitizes tool schemas to prevent 400 errors.
|
| 949 |
* **Dedicated Logging**: Implements `_IFlowFileLogger` to capture raw chunks for debugging proprietary API behaviors.
|
|
@@ -107,7 +107,7 @@ class SettingsDetector:
|
|
| 107 |
|
| 108 |
@staticmethod
|
| 109 |
def get_all_settings() -> dict:
|
| 110 |
-
"""Returns comprehensive settings overview"""
|
| 111 |
return {
|
| 112 |
"credentials": SettingsDetector.detect_credentials(),
|
| 113 |
"custom_bases": SettingsDetector.detect_custom_api_bases(),
|
|
@@ -117,6 +117,17 @@ class SettingsDetector:
|
|
| 117 |
"provider_settings": SettingsDetector.detect_provider_settings(),
|
| 118 |
}
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
@staticmethod
|
| 121 |
def detect_credentials() -> dict:
|
| 122 |
"""Detect API keys and OAuth credentials"""
|
|
@@ -277,8 +288,8 @@ class LauncherTUI:
|
|
| 277 |
"""Display main menu and handle selection"""
|
| 278 |
clear_screen()
|
| 279 |
|
| 280 |
-
# Detect
|
| 281 |
-
settings = SettingsDetector.
|
| 282 |
credentials = settings["credentials"]
|
| 283 |
custom_bases = settings["custom_bases"]
|
| 284 |
|
|
@@ -363,18 +374,17 @@ class LauncherTUI:
|
|
| 363 |
self.console.print("━" * 70)
|
| 364 |
provider_count = len(credentials)
|
| 365 |
custom_count = len(custom_bases)
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
| 367 |
has_advanced = bool(
|
| 368 |
settings["model_definitions"]
|
| 369 |
or settings["concurrency_limits"]
|
| 370 |
or settings["model_filters"]
|
| 371 |
-
or provider_settings
|
| 372 |
)
|
| 373 |
-
|
| 374 |
-
self.console.print(f" Providers: {provider_count} configured")
|
| 375 |
-
self.console.print(f" Custom Providers: {custom_count} configured")
|
| 376 |
self.console.print(
|
| 377 |
-
f" Advanced Settings: {'Active (view in menu 4)' if has_advanced else 'None'}"
|
| 378 |
)
|
| 379 |
|
| 380 |
# Show menu
|
|
@@ -659,13 +669,14 @@ class LauncherTUI:
|
|
| 659 |
"""Display provider/advanced settings (read-only + launch tool)"""
|
| 660 |
clear_screen()
|
| 661 |
|
| 662 |
-
settings
|
|
|
|
|
|
|
| 663 |
credentials = settings["credentials"]
|
| 664 |
custom_bases = settings["custom_bases"]
|
| 665 |
model_defs = settings["model_definitions"]
|
| 666 |
concurrency = settings["concurrency_limits"]
|
| 667 |
filters = settings["model_filters"]
|
| 668 |
-
provider_settings = settings.get("provider_settings", {})
|
| 669 |
|
| 670 |
self.console.print(
|
| 671 |
Panel.fit(
|
|
@@ -740,23 +751,13 @@ class LauncherTUI:
|
|
| 740 |
status = " + ".join(status_parts) if status_parts else "None"
|
| 741 |
self.console.print(f" • {provider:15} ✅ {status}")
|
| 742 |
|
| 743 |
-
# Provider-Specific Settings
|
| 744 |
self.console.print()
|
| 745 |
self.console.print("[bold]🔬 Provider-Specific Settings[/bold]")
|
| 746 |
self.console.print("━" * 70)
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
from .settings_tool import PROVIDER_SETTINGS_MAP
|
| 751 |
-
for provider in PROVIDER_SETTINGS_MAP.keys():
|
| 752 |
-
display_name = provider.replace("_", " ").title()
|
| 753 |
-
modified = provider_settings.get(provider, 0)
|
| 754 |
-
if modified > 0:
|
| 755 |
-
self.console.print(
|
| 756 |
-
f" • {display_name:20} [yellow]{modified} setting{'s' if modified > 1 else ''} modified[/yellow]"
|
| 757 |
-
)
|
| 758 |
-
else:
|
| 759 |
-
self.console.print(f" • {display_name:20} [dim]using defaults[/dim]")
|
| 760 |
|
| 761 |
# Actions
|
| 762 |
self.console.print()
|
|
@@ -827,7 +828,23 @@ class LauncherTUI:
|
|
| 827 |
|
| 828 |
def launch_settings_tool(self):
|
| 829 |
"""Launch settings configuration tool"""
|
| 830 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 831 |
|
| 832 |
run_settings_tool()
|
| 833 |
# Reload environment after settings tool
|
|
|
|
| 107 |
|
| 108 |
@staticmethod
|
| 109 |
def get_all_settings() -> dict:
|
| 110 |
+
"""Returns comprehensive settings overview (includes provider_settings which triggers heavy imports)"""
|
| 111 |
return {
|
| 112 |
"credentials": SettingsDetector.detect_credentials(),
|
| 113 |
"custom_bases": SettingsDetector.detect_custom_api_bases(),
|
|
|
|
| 117 |
"provider_settings": SettingsDetector.detect_provider_settings(),
|
| 118 |
}
|
| 119 |
|
| 120 |
+
@staticmethod
|
| 121 |
+
def get_basic_settings() -> dict:
|
| 122 |
+
"""Returns basic settings overview without provider_settings (avoids heavy imports)"""
|
| 123 |
+
return {
|
| 124 |
+
"credentials": SettingsDetector.detect_credentials(),
|
| 125 |
+
"custom_bases": SettingsDetector.detect_custom_api_bases(),
|
| 126 |
+
"model_definitions": SettingsDetector.detect_model_definitions(),
|
| 127 |
+
"concurrency_limits": SettingsDetector.detect_concurrency_limits(),
|
| 128 |
+
"model_filters": SettingsDetector.detect_model_filters(),
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
@staticmethod
|
| 132 |
def detect_credentials() -> dict:
|
| 133 |
"""Detect API keys and OAuth credentials"""
|
|
|
|
| 288 |
"""Display main menu and handle selection"""
|
| 289 |
clear_screen()
|
| 290 |
|
| 291 |
+
# Detect basic settings (excludes provider_settings to avoid heavy imports)
|
| 292 |
+
settings = SettingsDetector.get_basic_settings()
|
| 293 |
credentials = settings["credentials"]
|
| 294 |
custom_bases = settings["custom_bases"]
|
| 295 |
|
|
|
|
| 374 |
self.console.print("━" * 70)
|
| 375 |
provider_count = len(credentials)
|
| 376 |
custom_count = len(custom_bases)
|
| 377 |
+
|
| 378 |
+
self.console.print(f" Providers: {provider_count} configured")
|
| 379 |
+
self.console.print(f" Custom Providers: {custom_count} configured")
|
| 380 |
+
# Note: provider_settings detection is deferred to avoid heavy imports on startup
|
| 381 |
has_advanced = bool(
|
| 382 |
settings["model_definitions"]
|
| 383 |
or settings["concurrency_limits"]
|
| 384 |
or settings["model_filters"]
|
|
|
|
| 385 |
)
|
|
|
|
|
|
|
|
|
|
| 386 |
self.console.print(
|
| 387 |
+
f" Advanced Settings: {'Active (view in menu 4)' if has_advanced else 'None (view menu 4 for details)'}"
|
| 388 |
)
|
| 389 |
|
| 390 |
# Show menu
|
|
|
|
| 669 |
"""Display provider/advanced settings (read-only + launch tool)"""
|
| 670 |
clear_screen()
|
| 671 |
|
| 672 |
+
# Use basic settings to avoid heavy imports - provider_settings deferred to Settings Tool
|
| 673 |
+
settings = SettingsDetector.get_basic_settings()
|
| 674 |
+
|
| 675 |
credentials = settings["credentials"]
|
| 676 |
custom_bases = settings["custom_bases"]
|
| 677 |
model_defs = settings["model_definitions"]
|
| 678 |
concurrency = settings["concurrency_limits"]
|
| 679 |
filters = settings["model_filters"]
|
|
|
|
| 680 |
|
| 681 |
self.console.print(
|
| 682 |
Panel.fit(
|
|
|
|
| 751 |
status = " + ".join(status_parts) if status_parts else "None"
|
| 752 |
self.console.print(f" • {provider:15} ✅ {status}")
|
| 753 |
|
| 754 |
+
# Provider-Specific Settings (deferred to Settings Tool to avoid heavy imports)
|
| 755 |
self.console.print()
|
| 756 |
self.console.print("[bold]🔬 Provider-Specific Settings[/bold]")
|
| 757 |
self.console.print("━" * 70)
|
| 758 |
+
self.console.print(
|
| 759 |
+
" [dim]Launch Settings Tool to view/configure provider-specific settings[/dim]"
|
| 760 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 761 |
|
| 762 |
# Actions
|
| 763 |
self.console.print()
|
|
|
|
| 828 |
|
| 829 |
def launch_settings_tool(self):
|
| 830 |
"""Launch settings configuration tool"""
|
| 831 |
+
import time
|
| 832 |
+
|
| 833 |
+
clear_screen()
|
| 834 |
+
|
| 835 |
+
self.console.print("━" * 70)
|
| 836 |
+
self.console.print("Advanced Settings Configuration Tool")
|
| 837 |
+
self.console.print("━" * 70)
|
| 838 |
+
|
| 839 |
+
_start_time = time.time()
|
| 840 |
+
|
| 841 |
+
with self.console.status("Initializing settings tool...", spinner="dots"):
|
| 842 |
+
from proxy_app.settings_tool import run_settings_tool
|
| 843 |
+
|
| 844 |
+
_elapsed = time.time() - _start_time
|
| 845 |
+
self.console.print(f"✓ Settings tool ready in {_elapsed:.2f}s")
|
| 846 |
+
|
| 847 |
+
time.sleep(0.3)
|
| 848 |
|
| 849 |
run_settings_tool()
|
| 850 |
# Reload environment after settings tool
|
|
@@ -14,6 +14,29 @@ from dotenv import set_key, unset_key
|
|
| 14 |
|
| 15 |
console = Console()
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
def clear_screen():
|
| 19 |
"""
|
|
@@ -383,6 +406,11 @@ ANTIGRAVITY_SETTINGS = {
|
|
| 383 |
"default": "\n\nSTRICT PARAMETERS: {params}.",
|
| 384 |
"description": "Template for Claude strict parameter hints in tool descriptions",
|
| 385 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
}
|
| 387 |
|
| 388 |
# Gemini CLI provider environment variables
|
|
@@ -427,12 +455,27 @@ GEMINI_CLI_SETTINGS = {
|
|
| 427 |
"default": "",
|
| 428 |
"description": "GCP Project ID for paid tier users (required for paid tiers)",
|
| 429 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
}
|
| 431 |
|
| 432 |
# Map provider names to their settings definitions
|
| 433 |
PROVIDER_SETTINGS_MAP = {
|
| 434 |
"antigravity": ANTIGRAVITY_SETTINGS,
|
| 435 |
"gemini_cli": GEMINI_CLI_SETTINGS,
|
|
|
|
| 436 |
}
|
| 437 |
|
| 438 |
|
|
|
|
| 14 |
|
| 15 |
console = Console()
|
| 16 |
|
| 17 |
+
# Import default OAuth port values from provider modules
|
| 18 |
+
# These serve as the source of truth for default port values
|
| 19 |
+
try:
|
| 20 |
+
from rotator_library.providers.gemini_auth_base import GeminiAuthBase
|
| 21 |
+
|
| 22 |
+
GEMINI_CLI_DEFAULT_OAUTH_PORT = GeminiAuthBase.CALLBACK_PORT
|
| 23 |
+
except ImportError:
|
| 24 |
+
GEMINI_CLI_DEFAULT_OAUTH_PORT = 8085
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
from rotator_library.providers.antigravity_auth_base import AntigravityAuthBase
|
| 28 |
+
|
| 29 |
+
ANTIGRAVITY_DEFAULT_OAUTH_PORT = AntigravityAuthBase.CALLBACK_PORT
|
| 30 |
+
except ImportError:
|
| 31 |
+
ANTIGRAVITY_DEFAULT_OAUTH_PORT = 51121
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
from rotator_library.providers.iflow_auth_base import (
|
| 35 |
+
CALLBACK_PORT as IFLOW_DEFAULT_OAUTH_PORT,
|
| 36 |
+
)
|
| 37 |
+
except ImportError:
|
| 38 |
+
IFLOW_DEFAULT_OAUTH_PORT = 11451
|
| 39 |
+
|
| 40 |
|
| 41 |
def clear_screen():
|
| 42 |
"""
|
|
|
|
| 406 |
"default": "\n\nSTRICT PARAMETERS: {params}.",
|
| 407 |
"description": "Template for Claude strict parameter hints in tool descriptions",
|
| 408 |
},
|
| 409 |
+
"ANTIGRAVITY_OAUTH_PORT": {
|
| 410 |
+
"type": "int",
|
| 411 |
+
"default": ANTIGRAVITY_DEFAULT_OAUTH_PORT,
|
| 412 |
+
"description": "Local port for OAuth callback server during authentication",
|
| 413 |
+
},
|
| 414 |
}
|
| 415 |
|
| 416 |
# Gemini CLI provider environment variables
|
|
|
|
| 455 |
"default": "",
|
| 456 |
"description": "GCP Project ID for paid tier users (required for paid tiers)",
|
| 457 |
},
|
| 458 |
+
"GEMINI_CLI_OAUTH_PORT": {
|
| 459 |
+
"type": "int",
|
| 460 |
+
"default": GEMINI_CLI_DEFAULT_OAUTH_PORT,
|
| 461 |
+
"description": "Local port for OAuth callback server during authentication",
|
| 462 |
+
},
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
# iFlow provider environment variables
|
| 466 |
+
IFLOW_SETTINGS = {
|
| 467 |
+
"IFLOW_OAUTH_PORT": {
|
| 468 |
+
"type": "int",
|
| 469 |
+
"default": IFLOW_DEFAULT_OAUTH_PORT,
|
| 470 |
+
"description": "Local port for OAuth callback server during authentication",
|
| 471 |
+
},
|
| 472 |
}
|
| 473 |
|
| 474 |
# Map provider names to their settings definitions
|
| 475 |
PROVIDER_SETTINGS_MAP = {
|
| 476 |
"antigravity": ANTIGRAVITY_SETTINGS,
|
| 477 |
"gemini_cli": GEMINI_CLI_SETTINGS,
|
| 478 |
+
"iflow": IFLOW_SETTINGS,
|
| 479 |
}
|
| 480 |
|
| 481 |
|
|
@@ -54,6 +54,25 @@ class GoogleOAuthBase:
|
|
| 54 |
CALLBACK_PATH: str = "/oauth2callback"
|
| 55 |
REFRESH_EXPIRY_BUFFER_SECONDS: int = 30 * 60 # 30 minutes
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
def __init__(self):
|
| 58 |
# Validate that subclass has set required attributes
|
| 59 |
if self.CLIENT_ID is None:
|
|
@@ -701,14 +720,14 @@ class GoogleOAuthBase:
|
|
| 701 |
|
| 702 |
try:
|
| 703 |
server = await asyncio.start_server(
|
| 704 |
-
handle_callback, "127.0.0.1", self.
|
| 705 |
)
|
| 706 |
from urllib.parse import urlencode
|
| 707 |
|
| 708 |
auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(
|
| 709 |
{
|
| 710 |
"client_id": self.CLIENT_ID,
|
| 711 |
-
"redirect_uri": f"http://localhost:{self.
|
| 712 |
"scope": " ".join(self.OAUTH_SCOPES),
|
| 713 |
"access_type": "offline",
|
| 714 |
"response_type": "code",
|
|
@@ -783,7 +802,7 @@ class GoogleOAuthBase:
|
|
| 783 |
"code": auth_code.strip(),
|
| 784 |
"client_id": self.CLIENT_ID,
|
| 785 |
"client_secret": self.CLIENT_SECRET,
|
| 786 |
-
"redirect_uri": f"http://localhost:{self.
|
| 787 |
"grant_type": "authorization_code",
|
| 788 |
},
|
| 789 |
)
|
|
|
|
| 54 |
CALLBACK_PATH: str = "/oauth2callback"
|
| 55 |
REFRESH_EXPIRY_BUFFER_SECONDS: int = 30 * 60 # 30 minutes
|
| 56 |
|
| 57 |
+
@property
|
| 58 |
+
def callback_port(self) -> int:
|
| 59 |
+
"""
|
| 60 |
+
Get the OAuth callback port, checking environment variable first.
|
| 61 |
+
|
| 62 |
+
Reads from {ENV_PREFIX}_OAUTH_PORT environment variable, falling back
|
| 63 |
+
to the class's CALLBACK_PORT default if not set.
|
| 64 |
+
"""
|
| 65 |
+
env_var = f"{self.ENV_PREFIX}_OAUTH_PORT"
|
| 66 |
+
env_value = os.getenv(env_var)
|
| 67 |
+
if env_value:
|
| 68 |
+
try:
|
| 69 |
+
return int(env_value)
|
| 70 |
+
except ValueError:
|
| 71 |
+
lib_logger.warning(
|
| 72 |
+
f"Invalid {env_var} value: {env_value}, using default {self.CALLBACK_PORT}"
|
| 73 |
+
)
|
| 74 |
+
return self.CALLBACK_PORT
|
| 75 |
+
|
| 76 |
def __init__(self):
|
| 77 |
# Validate that subclass has set required attributes
|
| 78 |
if self.CLIENT_ID is None:
|
|
|
|
| 720 |
|
| 721 |
try:
|
| 722 |
server = await asyncio.start_server(
|
| 723 |
+
handle_callback, "127.0.0.1", self.callback_port
|
| 724 |
)
|
| 725 |
from urllib.parse import urlencode
|
| 726 |
|
| 727 |
auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(
|
| 728 |
{
|
| 729 |
"client_id": self.CLIENT_ID,
|
| 730 |
+
"redirect_uri": f"http://localhost:{self.callback_port}{self.CALLBACK_PATH}",
|
| 731 |
"scope": " ".join(self.OAUTH_SCOPES),
|
| 732 |
"access_type": "offline",
|
| 733 |
"response_type": "code",
|
|
|
|
| 802 |
"code": auth_code.strip(),
|
| 803 |
"client_id": self.CLIENT_ID,
|
| 804 |
"client_secret": self.CLIENT_SECRET,
|
| 805 |
+
"redirect_uri": f"http://localhost:{self.callback_port}{self.CALLBACK_PATH}",
|
| 806 |
"grant_type": "authorization_code",
|
| 807 |
},
|
| 808 |
)
|
|
@@ -39,6 +39,25 @@ IFLOW_CLIENT_SECRET = "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW"
|
|
| 39 |
# Local callback server port
|
| 40 |
CALLBACK_PORT = 11451
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
# Refresh tokens 24 hours before expiry
|
| 43 |
REFRESH_EXPIRY_BUFFER_SECONDS = 24 * 60 * 60
|
| 44 |
|
|
@@ -931,7 +950,8 @@ class IFlowAuthBase:
|
|
| 931 |
state = secrets.token_urlsafe(32)
|
| 932 |
|
| 933 |
# Build authorization URL
|
| 934 |
-
|
|
|
|
| 935 |
auth_params = {
|
| 936 |
"loginMethod": "phone",
|
| 937 |
"type": "phone",
|
|
@@ -942,7 +962,7 @@ class IFlowAuthBase:
|
|
| 942 |
auth_url = f"{IFLOW_OAUTH_AUTHORIZE_ENDPOINT}?{urlencode(auth_params)}"
|
| 943 |
|
| 944 |
# Start OAuth callback server
|
| 945 |
-
callback_server = OAuthCallbackServer(port=
|
| 946 |
try:
|
| 947 |
await callback_server.start(expected_state=state)
|
| 948 |
|
|
|
|
| 39 |
# Local callback server port
|
| 40 |
CALLBACK_PORT = 11451
|
| 41 |
|
| 42 |
+
|
| 43 |
+
def get_callback_port() -> int:
|
| 44 |
+
"""
|
| 45 |
+
Get the OAuth callback port, checking environment variable first.
|
| 46 |
+
|
| 47 |
+
Reads from IFLOW_OAUTH_PORT environment variable, falling back
|
| 48 |
+
to the default CALLBACK_PORT if not set.
|
| 49 |
+
"""
|
| 50 |
+
env_value = os.getenv("IFLOW_OAUTH_PORT")
|
| 51 |
+
if env_value:
|
| 52 |
+
try:
|
| 53 |
+
return int(env_value)
|
| 54 |
+
except ValueError:
|
| 55 |
+
logging.getLogger("rotator_library").warning(
|
| 56 |
+
f"Invalid IFLOW_OAUTH_PORT value: {env_value}, using default {CALLBACK_PORT}"
|
| 57 |
+
)
|
| 58 |
+
return CALLBACK_PORT
|
| 59 |
+
|
| 60 |
+
|
| 61 |
# Refresh tokens 24 hours before expiry
|
| 62 |
REFRESH_EXPIRY_BUFFER_SECONDS = 24 * 60 * 60
|
| 63 |
|
|
|
|
| 950 |
state = secrets.token_urlsafe(32)
|
| 951 |
|
| 952 |
# Build authorization URL
|
| 953 |
+
callback_port = get_callback_port()
|
| 954 |
+
redirect_uri = f"http://localhost:{callback_port}/oauth2callback"
|
| 955 |
auth_params = {
|
| 956 |
"loginMethod": "phone",
|
| 957 |
"type": "phone",
|
|
|
|
| 962 |
auth_url = f"{IFLOW_OAUTH_AUTHORIZE_ENDPOINT}?{urlencode(auth_params)}"
|
| 963 |
|
| 964 |
# Start OAuth callback server
|
| 965 |
+
callback_server = OAuthCallbackServer(port=callback_port)
|
| 966 |
try:
|
| 967 |
await callback_server.start(expected_state=state)
|
| 968 |
|