diff --git a/cmd/server/main.go b/cmd/server/main.go index f9bb2080..385d7cfa 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -61,6 +61,7 @@ func main() { var iflowLogin bool var iflowCookie bool var noBrowser bool + var oauthCallbackPort int var antigravityLogin bool var projectID string var vertexImport string @@ -75,6 +76,7 @@ func main() { flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth") flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie") flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth") + flag.IntVar(&oauthCallbackPort, "oauth-callback-port", 0, "Override OAuth callback port (defaults to provider-specific port)") flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth") flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path") @@ -425,7 +427,8 @@ func main() { // Create login options to be used in authentication flows. options := &cmd.LoginOptions{ - NoBrowser: noBrowser, + NoBrowser: noBrowser, + CallbackPort: oauthCallbackPort, } // Register the shared token store once so all components use the same persistence backend. diff --git a/internal/auth/gemini/gemini_auth.go b/internal/auth/gemini/gemini_auth.go index 7b18e738..708ac809 100644 --- a/internal/auth/gemini/gemini_auth.go +++ b/internal/auth/gemini/gemini_auth.go @@ -29,8 +29,9 @@ import ( ) const ( - geminiOauthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" - geminiOauthClientSecret = "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" + geminiOauthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" + geminiOauthClientSecret = "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" + geminiDefaultCallbackPort = 8085 ) var ( @@ -49,8 +50,9 @@ type GeminiAuth struct { // WebLoginOptions customizes the interactive OAuth flow. type WebLoginOptions struct { - NoBrowser bool - Prompt func(string) (string, error) + NoBrowser bool + CallbackPort int + Prompt func(string) (string, error) } // NewGeminiAuth creates a new instance of GeminiAuth. @@ -72,6 +74,12 @@ func NewGeminiAuth() *GeminiAuth { // - *http.Client: An HTTP client configured with authentication // - error: An error if the client configuration fails, nil otherwise func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiTokenStorage, cfg *config.Config, opts *WebLoginOptions) (*http.Client, error) { + callbackPort := geminiDefaultCallbackPort + if opts != nil && opts.CallbackPort > 0 { + callbackPort = opts.CallbackPort + } + callbackURL := fmt.Sprintf("http://localhost:%d/oauth2callback", callbackPort) + // Configure proxy settings for the HTTP client if a proxy URL is provided. proxyURL, err := url.Parse(cfg.ProxyURL) if err == nil { @@ -106,7 +114,7 @@ func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiToken conf := &oauth2.Config{ ClientID: geminiOauthClientID, ClientSecret: geminiOauthClientSecret, - RedirectURL: "http://localhost:8085/oauth2callback", // This will be used by the local server. + RedirectURL: callbackURL, // This will be used by the local server. Scopes: geminiOauthScopes, Endpoint: google.Endpoint, } @@ -218,14 +226,20 @@ func (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Conf // - *oauth2.Token: The OAuth2 token obtained from the authorization flow // - error: An error if the token acquisition fails, nil otherwise func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config, opts *WebLoginOptions) (*oauth2.Token, error) { + callbackPort := geminiDefaultCallbackPort + if opts != nil && opts.CallbackPort > 0 { + callbackPort = opts.CallbackPort + } + callbackURL := fmt.Sprintf("http://localhost:%d/oauth2callback", callbackPort) + // Use a channel to pass the authorization code from the HTTP handler to the main function. codeChan := make(chan string, 1) errChan := make(chan error, 1) // Create a new HTTP server with its own multiplexer. mux := http.NewServeMux() - server := &http.Server{Addr: ":8085", Handler: mux} - config.RedirectURL = "http://localhost:8085/oauth2callback" + server := &http.Server{Addr: fmt.Sprintf(":%d", callbackPort), Handler: mux} + config.RedirectURL = callbackURL mux.HandleFunc("/oauth2callback", func(w http.ResponseWriter, r *http.Request) { if err := r.URL.Query().Get("error"); err != "" { @@ -277,13 +291,13 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config, // Check if browser is available if !browser.IsAvailable() { log.Warn("No browser available on this system") - util.PrintSSHTunnelInstructions(8085) + util.PrintSSHTunnelInstructions(callbackPort) fmt.Printf("Please manually open this URL in your browser:\n\n%s\n", authURL) } else { if err := browser.OpenURL(authURL); err != nil { authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err) log.Warn(codex.GetUserFriendlyMessage(authErr)) - util.PrintSSHTunnelInstructions(8085) + util.PrintSSHTunnelInstructions(callbackPort) fmt.Printf("Please manually open this URL in your browser:\n\n%s\n", authURL) // Log platform info for debugging @@ -294,7 +308,7 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config, } } } else { - util.PrintSSHTunnelInstructions(8085) + util.PrintSSHTunnelInstructions(callbackPort) fmt.Printf("Please open this URL in your browser:\n\n%s\n", authURL) } diff --git a/internal/cmd/anthropic_login.go b/internal/cmd/anthropic_login.go index 6efd87a8..dafdd02b 100644 --- a/internal/cmd/anthropic_login.go +++ b/internal/cmd/anthropic_login.go @@ -32,9 +32,10 @@ func DoClaudeLogin(cfg *config.Config, options *LoginOptions) { manager := newAuthManager() authOpts := &sdkAuth.LoginOptions{ - NoBrowser: options.NoBrowser, - Metadata: map[string]string{}, - Prompt: promptFn, + NoBrowser: options.NoBrowser, + CallbackPort: options.CallbackPort, + Metadata: map[string]string{}, + Prompt: promptFn, } _, savedPath, err := manager.Login(context.Background(), "claude", cfg, authOpts) diff --git a/internal/cmd/antigravity_login.go b/internal/cmd/antigravity_login.go index 1cd42899..2efbaeee 100644 --- a/internal/cmd/antigravity_login.go +++ b/internal/cmd/antigravity_login.go @@ -22,9 +22,10 @@ func DoAntigravityLogin(cfg *config.Config, options *LoginOptions) { manager := newAuthManager() authOpts := &sdkAuth.LoginOptions{ - NoBrowser: options.NoBrowser, - Metadata: map[string]string{}, - Prompt: promptFn, + NoBrowser: options.NoBrowser, + CallbackPort: options.CallbackPort, + Metadata: map[string]string{}, + Prompt: promptFn, } record, savedPath, err := manager.Login(context.Background(), "antigravity", cfg, authOpts) diff --git a/internal/cmd/iflow_login.go b/internal/cmd/iflow_login.go index cf00b63c..07360b8c 100644 --- a/internal/cmd/iflow_login.go +++ b/internal/cmd/iflow_login.go @@ -24,9 +24,10 @@ func DoIFlowLogin(cfg *config.Config, options *LoginOptions) { } authOpts := &sdkAuth.LoginOptions{ - NoBrowser: options.NoBrowser, - Metadata: map[string]string{}, - Prompt: promptFn, + NoBrowser: options.NoBrowser, + CallbackPort: options.CallbackPort, + Metadata: map[string]string{}, + Prompt: promptFn, } _, savedPath, err := manager.Login(context.Background(), "iflow", cfg, authOpts) diff --git a/internal/cmd/login.go b/internal/cmd/login.go index 3bb0b9a5..558dacf6 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -67,10 +67,11 @@ func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) { } loginOpts := &sdkAuth.LoginOptions{ - NoBrowser: options.NoBrowser, - ProjectID: trimmedProjectID, - Metadata: map[string]string{}, - Prompt: callbackPrompt, + NoBrowser: options.NoBrowser, + ProjectID: trimmedProjectID, + CallbackPort: options.CallbackPort, + Metadata: map[string]string{}, + Prompt: callbackPrompt, } authenticator := sdkAuth.NewGeminiAuthenticator() @@ -88,8 +89,9 @@ func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) { geminiAuth := gemini.NewGeminiAuth() httpClient, errClient := geminiAuth.GetAuthenticatedClient(ctx, storage, cfg, &gemini.WebLoginOptions{ - NoBrowser: options.NoBrowser, - Prompt: callbackPrompt, + NoBrowser: options.NoBrowser, + CallbackPort: options.CallbackPort, + Prompt: callbackPrompt, }) if errClient != nil { log.Errorf("Gemini authentication failed: %v", errClient) diff --git a/internal/cmd/openai_login.go b/internal/cmd/openai_login.go index d981f6ae..5f2fb162 100644 --- a/internal/cmd/openai_login.go +++ b/internal/cmd/openai_login.go @@ -19,6 +19,9 @@ type LoginOptions struct { // NoBrowser indicates whether to skip opening the browser automatically. NoBrowser bool + // CallbackPort overrides the local OAuth callback port when set (>0). + CallbackPort int + // Prompt allows the caller to provide interactive input when needed. Prompt func(prompt string) (string, error) } @@ -43,9 +46,10 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) { manager := newAuthManager() authOpts := &sdkAuth.LoginOptions{ - NoBrowser: options.NoBrowser, - Metadata: map[string]string{}, - Prompt: promptFn, + NoBrowser: options.NoBrowser, + CallbackPort: options.CallbackPort, + Metadata: map[string]string{}, + Prompt: promptFn, } _, savedPath, err := manager.Login(context.Background(), "codex", cfg, authOpts) diff --git a/internal/cmd/qwen_login.go b/internal/cmd/qwen_login.go index 27edf408..92a57aa5 100644 --- a/internal/cmd/qwen_login.go +++ b/internal/cmd/qwen_login.go @@ -36,9 +36,10 @@ func DoQwenLogin(cfg *config.Config, options *LoginOptions) { } authOpts := &sdkAuth.LoginOptions{ - NoBrowser: options.NoBrowser, - Metadata: map[string]string{}, - Prompt: promptFn, + NoBrowser: options.NoBrowser, + CallbackPort: options.CallbackPort, + Metadata: map[string]string{}, + Prompt: promptFn, } _, savedPath, err := manager.Login(context.Background(), "qwen", cfg, authOpts) diff --git a/sdk/auth/antigravity.go b/sdk/auth/antigravity.go index ae22f772..b59acacf 100644 --- a/sdk/auth/antigravity.go +++ b/sdk/auth/antigravity.go @@ -60,6 +60,11 @@ func (AntigravityAuthenticator) Login(ctx context.Context, cfg *config.Config, o opts = &LoginOptions{} } + callbackPort := antigravityCallbackPort + if opts.CallbackPort > 0 { + callbackPort = opts.CallbackPort + } + httpClient := util.SetProxy(&cfg.SDKConfig, &http.Client{}) state, err := misc.GenerateRandomState() @@ -67,7 +72,7 @@ func (AntigravityAuthenticator) Login(ctx context.Context, cfg *config.Config, o return nil, fmt.Errorf("antigravity: failed to generate state: %w", err) } - srv, port, cbChan, errServer := startAntigravityCallbackServer() + srv, port, cbChan, errServer := startAntigravityCallbackServer(callbackPort) if errServer != nil { return nil, fmt.Errorf("antigravity: failed to start callback server: %w", errServer) } @@ -224,13 +229,16 @@ type callbackResult struct { State string } -func startAntigravityCallbackServer() (*http.Server, int, <-chan callbackResult, error) { - addr := fmt.Sprintf(":%d", antigravityCallbackPort) +func startAntigravityCallbackServer(port int) (*http.Server, int, <-chan callbackResult, error) { + if port <= 0 { + port = antigravityCallbackPort + } + addr := fmt.Sprintf(":%d", port) listener, err := net.Listen("tcp", addr) if err != nil { return nil, 0, nil, err } - port := listener.Addr().(*net.TCPAddr).Port + port = listener.Addr().(*net.TCPAddr).Port resultCh := make(chan callbackResult, 1) mux := http.NewServeMux() diff --git a/sdk/auth/claude.go b/sdk/auth/claude.go index c43b78cd..2c7a8988 100644 --- a/sdk/auth/claude.go +++ b/sdk/auth/claude.go @@ -47,6 +47,11 @@ func (a *ClaudeAuthenticator) Login(ctx context.Context, cfg *config.Config, opt opts = &LoginOptions{} } + callbackPort := a.CallbackPort + if opts.CallbackPort > 0 { + callbackPort = opts.CallbackPort + } + pkceCodes, err := claude.GeneratePKCECodes() if err != nil { return nil, fmt.Errorf("claude pkce generation failed: %w", err) @@ -57,7 +62,7 @@ func (a *ClaudeAuthenticator) Login(ctx context.Context, cfg *config.Config, opt return nil, fmt.Errorf("claude state generation failed: %w", err) } - oauthServer := claude.NewOAuthServer(a.CallbackPort) + oauthServer := claude.NewOAuthServer(callbackPort) if err = oauthServer.Start(); err != nil { if strings.Contains(err.Error(), "already in use") { return nil, claude.NewAuthenticationError(claude.ErrPortInUse, err) @@ -84,15 +89,15 @@ func (a *ClaudeAuthenticator) Login(ctx context.Context, cfg *config.Config, opt fmt.Println("Opening browser for Claude authentication") if !browser.IsAvailable() { log.Warn("No browser available; please open the URL manually") - util.PrintSSHTunnelInstructions(a.CallbackPort) + util.PrintSSHTunnelInstructions(callbackPort) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) } else if err = browser.OpenURL(authURL); err != nil { log.Warnf("Failed to open browser automatically: %v", err) - util.PrintSSHTunnelInstructions(a.CallbackPort) + util.PrintSSHTunnelInstructions(callbackPort) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) } } else { - util.PrintSSHTunnelInstructions(a.CallbackPort) + util.PrintSSHTunnelInstructions(callbackPort) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) } diff --git a/sdk/auth/codex.go b/sdk/auth/codex.go index 99992525..b3104b4e 100644 --- a/sdk/auth/codex.go +++ b/sdk/auth/codex.go @@ -47,6 +47,11 @@ func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts opts = &LoginOptions{} } + callbackPort := a.CallbackPort + if opts.CallbackPort > 0 { + callbackPort = opts.CallbackPort + } + pkceCodes, err := codex.GeneratePKCECodes() if err != nil { return nil, fmt.Errorf("codex pkce generation failed: %w", err) @@ -57,7 +62,7 @@ func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts return nil, fmt.Errorf("codex state generation failed: %w", err) } - oauthServer := codex.NewOAuthServer(a.CallbackPort) + oauthServer := codex.NewOAuthServer(callbackPort) if err = oauthServer.Start(); err != nil { if strings.Contains(err.Error(), "already in use") { return nil, codex.NewAuthenticationError(codex.ErrPortInUse, err) @@ -83,15 +88,15 @@ func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts fmt.Println("Opening browser for Codex authentication") if !browser.IsAvailable() { log.Warn("No browser available; please open the URL manually") - util.PrintSSHTunnelInstructions(a.CallbackPort) + util.PrintSSHTunnelInstructions(callbackPort) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) } else if err = browser.OpenURL(authURL); err != nil { log.Warnf("Failed to open browser automatically: %v", err) - util.PrintSSHTunnelInstructions(a.CallbackPort) + util.PrintSSHTunnelInstructions(callbackPort) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) } } else { - util.PrintSSHTunnelInstructions(a.CallbackPort) + util.PrintSSHTunnelInstructions(callbackPort) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) } diff --git a/sdk/auth/gemini.go b/sdk/auth/gemini.go index 75ef4579..2b8f9c2b 100644 --- a/sdk/auth/gemini.go +++ b/sdk/auth/gemini.go @@ -45,8 +45,9 @@ func (a *GeminiAuthenticator) Login(ctx context.Context, cfg *config.Config, opt geminiAuth := gemini.NewGeminiAuth() _, err := geminiAuth.GetAuthenticatedClient(ctx, &ts, cfg, &gemini.WebLoginOptions{ - NoBrowser: opts.NoBrowser, - Prompt: opts.Prompt, + NoBrowser: opts.NoBrowser, + CallbackPort: opts.CallbackPort, + Prompt: opts.Prompt, }) if err != nil { return nil, fmt.Errorf("gemini authentication failed: %w", err) diff --git a/sdk/auth/iflow.go b/sdk/auth/iflow.go index 3fd82f1d..6d4ff946 100644 --- a/sdk/auth/iflow.go +++ b/sdk/auth/iflow.go @@ -42,9 +42,14 @@ func (a *IFlowAuthenticator) Login(ctx context.Context, cfg *config.Config, opts opts = &LoginOptions{} } + callbackPort := iflow.CallbackPort + if opts.CallbackPort > 0 { + callbackPort = opts.CallbackPort + } + authSvc := iflow.NewIFlowAuth(cfg) - oauthServer := iflow.NewOAuthServer(iflow.CallbackPort) + oauthServer := iflow.NewOAuthServer(callbackPort) if err := oauthServer.Start(); err != nil { if strings.Contains(err.Error(), "already in use") { return nil, fmt.Errorf("iflow authentication server port in use: %w", err) @@ -64,21 +69,21 @@ func (a *IFlowAuthenticator) Login(ctx context.Context, cfg *config.Config, opts return nil, fmt.Errorf("iflow auth: failed to generate state: %w", err) } - authURL, redirectURI := authSvc.AuthorizationURL(state, iflow.CallbackPort) + authURL, redirectURI := authSvc.AuthorizationURL(state, callbackPort) if !opts.NoBrowser { fmt.Println("Opening browser for iFlow authentication") if !browser.IsAvailable() { log.Warn("No browser available; please open the URL manually") - util.PrintSSHTunnelInstructions(iflow.CallbackPort) + util.PrintSSHTunnelInstructions(callbackPort) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) } else if err = browser.OpenURL(authURL); err != nil { log.Warnf("Failed to open browser automatically: %v", err) - util.PrintSSHTunnelInstructions(iflow.CallbackPort) + util.PrintSSHTunnelInstructions(callbackPort) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) } } else { - util.PrintSSHTunnelInstructions(iflow.CallbackPort) + util.PrintSSHTunnelInstructions(callbackPort) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) } diff --git a/sdk/auth/interfaces.go b/sdk/auth/interfaces.go index 7a7868e1..64cf8ed0 100644 --- a/sdk/auth/interfaces.go +++ b/sdk/auth/interfaces.go @@ -14,10 +14,11 @@ var ErrRefreshNotSupported = errors.New("cliproxy auth: refresh not supported") // LoginOptions captures generic knobs shared across authenticators. // Provider-specific logic can inspect Metadata for extra parameters. type LoginOptions struct { - NoBrowser bool - ProjectID string - Metadata map[string]string - Prompt func(prompt string) (string, error) + NoBrowser bool + ProjectID string + CallbackPort int + Metadata map[string]string + Prompt func(prompt string) (string, error) } // Authenticator manages login and optional refresh flows for a provider.