feat(oauth): add support for customizable OAuth callback ports

- Introduced `oauth-callback-port` flag to override default callback ports.
- Updated SDK and login flows for `iflow`, `gemini`, `antigravity`, `codex`, `claude`, and `openai` to respect configurable callback ports.
- Refactored internal OAuth servers to dynamically assign ports based on the provided options.
- Revised tests and documentation to reflect the new flag and behavior.
This commit is contained in:
Luis Pater
2026-01-14 04:29:15 +08:00
parent 43652d044c
commit a1da6ff5ac
14 changed files with 107 additions and 55 deletions

View File

@@ -61,6 +61,7 @@ func main() {
var iflowLogin bool var iflowLogin bool
var iflowCookie bool var iflowCookie bool
var noBrowser bool var noBrowser bool
var oauthCallbackPort int
var antigravityLogin bool var antigravityLogin bool
var projectID string var projectID string
var vertexImport string var vertexImport string
@@ -75,6 +76,7 @@ func main() {
flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth") flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie") 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.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.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path") flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
@@ -426,6 +428,7 @@ func main() {
// Create login options to be used in authentication flows. // Create login options to be used in authentication flows.
options := &cmd.LoginOptions{ options := &cmd.LoginOptions{
NoBrowser: noBrowser, NoBrowser: noBrowser,
CallbackPort: oauthCallbackPort,
} }
// Register the shared token store once so all components use the same persistence backend. // Register the shared token store once so all components use the same persistence backend.

View File

@@ -31,6 +31,7 @@ import (
const ( const (
geminiOauthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" geminiOauthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
geminiOauthClientSecret = "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" geminiOauthClientSecret = "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"
geminiDefaultCallbackPort = 8085
) )
var ( var (
@@ -50,6 +51,7 @@ type GeminiAuth struct {
// WebLoginOptions customizes the interactive OAuth flow. // WebLoginOptions customizes the interactive OAuth flow.
type WebLoginOptions struct { type WebLoginOptions struct {
NoBrowser bool NoBrowser bool
CallbackPort int
Prompt func(string) (string, error) Prompt func(string) (string, error)
} }
@@ -72,6 +74,12 @@ func NewGeminiAuth() *GeminiAuth {
// - *http.Client: An HTTP client configured with authentication // - *http.Client: An HTTP client configured with authentication
// - error: An error if the client configuration fails, nil otherwise // - 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) { 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. // Configure proxy settings for the HTTP client if a proxy URL is provided.
proxyURL, err := url.Parse(cfg.ProxyURL) proxyURL, err := url.Parse(cfg.ProxyURL)
if err == nil { if err == nil {
@@ -106,7 +114,7 @@ func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiToken
conf := &oauth2.Config{ conf := &oauth2.Config{
ClientID: geminiOauthClientID, ClientID: geminiOauthClientID,
ClientSecret: geminiOauthClientSecret, 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, Scopes: geminiOauthScopes,
Endpoint: google.Endpoint, 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 // - *oauth2.Token: The OAuth2 token obtained from the authorization flow
// - error: An error if the token acquisition fails, nil otherwise // - 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) { 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. // Use a channel to pass the authorization code from the HTTP handler to the main function.
codeChan := make(chan string, 1) codeChan := make(chan string, 1)
errChan := make(chan error, 1) errChan := make(chan error, 1)
// Create a new HTTP server with its own multiplexer. // Create a new HTTP server with its own multiplexer.
mux := http.NewServeMux() mux := http.NewServeMux()
server := &http.Server{Addr: ":8085", Handler: mux} server := &http.Server{Addr: fmt.Sprintf(":%d", callbackPort), Handler: mux}
config.RedirectURL = "http://localhost:8085/oauth2callback" config.RedirectURL = callbackURL
mux.HandleFunc("/oauth2callback", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/oauth2callback", func(w http.ResponseWriter, r *http.Request) {
if err := r.URL.Query().Get("error"); err != "" { 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 // Check if browser is available
if !browser.IsAvailable() { if !browser.IsAvailable() {
log.Warn("No browser available on this system") 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) fmt.Printf("Please manually open this URL in your browser:\n\n%s\n", authURL)
} else { } else {
if err := browser.OpenURL(authURL); err != nil { if err := browser.OpenURL(authURL); err != nil {
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err) authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
log.Warn(codex.GetUserFriendlyMessage(authErr)) 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) fmt.Printf("Please manually open this URL in your browser:\n\n%s\n", authURL)
// Log platform info for debugging // Log platform info for debugging
@@ -294,7 +308,7 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
} }
} }
} else { } else {
util.PrintSSHTunnelInstructions(8085) util.PrintSSHTunnelInstructions(callbackPort)
fmt.Printf("Please open this URL in your browser:\n\n%s\n", authURL) fmt.Printf("Please open this URL in your browser:\n\n%s\n", authURL)
} }

View File

@@ -33,6 +33,7 @@ func DoClaudeLogin(cfg *config.Config, options *LoginOptions) {
authOpts := &sdkAuth.LoginOptions{ authOpts := &sdkAuth.LoginOptions{
NoBrowser: options.NoBrowser, NoBrowser: options.NoBrowser,
CallbackPort: options.CallbackPort,
Metadata: map[string]string{}, Metadata: map[string]string{},
Prompt: promptFn, Prompt: promptFn,
} }

View File

@@ -23,6 +23,7 @@ func DoAntigravityLogin(cfg *config.Config, options *LoginOptions) {
manager := newAuthManager() manager := newAuthManager()
authOpts := &sdkAuth.LoginOptions{ authOpts := &sdkAuth.LoginOptions{
NoBrowser: options.NoBrowser, NoBrowser: options.NoBrowser,
CallbackPort: options.CallbackPort,
Metadata: map[string]string{}, Metadata: map[string]string{},
Prompt: promptFn, Prompt: promptFn,
} }

View File

@@ -25,6 +25,7 @@ func DoIFlowLogin(cfg *config.Config, options *LoginOptions) {
authOpts := &sdkAuth.LoginOptions{ authOpts := &sdkAuth.LoginOptions{
NoBrowser: options.NoBrowser, NoBrowser: options.NoBrowser,
CallbackPort: options.CallbackPort,
Metadata: map[string]string{}, Metadata: map[string]string{},
Prompt: promptFn, Prompt: promptFn,
} }

View File

@@ -69,6 +69,7 @@ func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) {
loginOpts := &sdkAuth.LoginOptions{ loginOpts := &sdkAuth.LoginOptions{
NoBrowser: options.NoBrowser, NoBrowser: options.NoBrowser,
ProjectID: trimmedProjectID, ProjectID: trimmedProjectID,
CallbackPort: options.CallbackPort,
Metadata: map[string]string{}, Metadata: map[string]string{},
Prompt: callbackPrompt, Prompt: callbackPrompt,
} }
@@ -89,6 +90,7 @@ func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) {
geminiAuth := gemini.NewGeminiAuth() geminiAuth := gemini.NewGeminiAuth()
httpClient, errClient := geminiAuth.GetAuthenticatedClient(ctx, storage, cfg, &gemini.WebLoginOptions{ httpClient, errClient := geminiAuth.GetAuthenticatedClient(ctx, storage, cfg, &gemini.WebLoginOptions{
NoBrowser: options.NoBrowser, NoBrowser: options.NoBrowser,
CallbackPort: options.CallbackPort,
Prompt: callbackPrompt, Prompt: callbackPrompt,
}) })
if errClient != nil { if errClient != nil {

View File

@@ -19,6 +19,9 @@ type LoginOptions struct {
// NoBrowser indicates whether to skip opening the browser automatically. // NoBrowser indicates whether to skip opening the browser automatically.
NoBrowser bool 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 allows the caller to provide interactive input when needed.
Prompt func(prompt string) (string, error) Prompt func(prompt string) (string, error)
} }
@@ -44,6 +47,7 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) {
authOpts := &sdkAuth.LoginOptions{ authOpts := &sdkAuth.LoginOptions{
NoBrowser: options.NoBrowser, NoBrowser: options.NoBrowser,
CallbackPort: options.CallbackPort,
Metadata: map[string]string{}, Metadata: map[string]string{},
Prompt: promptFn, Prompt: promptFn,
} }

View File

@@ -37,6 +37,7 @@ func DoQwenLogin(cfg *config.Config, options *LoginOptions) {
authOpts := &sdkAuth.LoginOptions{ authOpts := &sdkAuth.LoginOptions{
NoBrowser: options.NoBrowser, NoBrowser: options.NoBrowser,
CallbackPort: options.CallbackPort,
Metadata: map[string]string{}, Metadata: map[string]string{},
Prompt: promptFn, Prompt: promptFn,
} }

View File

@@ -60,6 +60,11 @@ func (AntigravityAuthenticator) Login(ctx context.Context, cfg *config.Config, o
opts = &LoginOptions{} opts = &LoginOptions{}
} }
callbackPort := antigravityCallbackPort
if opts.CallbackPort > 0 {
callbackPort = opts.CallbackPort
}
httpClient := util.SetProxy(&cfg.SDKConfig, &http.Client{}) httpClient := util.SetProxy(&cfg.SDKConfig, &http.Client{})
state, err := misc.GenerateRandomState() 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) 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 { if errServer != nil {
return nil, fmt.Errorf("antigravity: failed to start callback server: %w", errServer) return nil, fmt.Errorf("antigravity: failed to start callback server: %w", errServer)
} }
@@ -224,13 +229,16 @@ type callbackResult struct {
State string State string
} }
func startAntigravityCallbackServer() (*http.Server, int, <-chan callbackResult, error) { func startAntigravityCallbackServer(port int) (*http.Server, int, <-chan callbackResult, error) {
addr := fmt.Sprintf(":%d", antigravityCallbackPort) if port <= 0 {
port = antigravityCallbackPort
}
addr := fmt.Sprintf(":%d", port)
listener, err := net.Listen("tcp", addr) listener, err := net.Listen("tcp", addr)
if err != nil { if err != nil {
return nil, 0, nil, err return nil, 0, nil, err
} }
port := listener.Addr().(*net.TCPAddr).Port port = listener.Addr().(*net.TCPAddr).Port
resultCh := make(chan callbackResult, 1) resultCh := make(chan callbackResult, 1)
mux := http.NewServeMux() mux := http.NewServeMux()

View File

@@ -47,6 +47,11 @@ func (a *ClaudeAuthenticator) Login(ctx context.Context, cfg *config.Config, opt
opts = &LoginOptions{} opts = &LoginOptions{}
} }
callbackPort := a.CallbackPort
if opts.CallbackPort > 0 {
callbackPort = opts.CallbackPort
}
pkceCodes, err := claude.GeneratePKCECodes() pkceCodes, err := claude.GeneratePKCECodes()
if err != nil { if err != nil {
return nil, fmt.Errorf("claude pkce generation failed: %w", err) 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) 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 err = oauthServer.Start(); err != nil {
if strings.Contains(err.Error(), "already in use") { if strings.Contains(err.Error(), "already in use") {
return nil, claude.NewAuthenticationError(claude.ErrPortInUse, err) 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") fmt.Println("Opening browser for Claude authentication")
if !browser.IsAvailable() { if !browser.IsAvailable() {
log.Warn("No browser available; please open the URL manually") 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) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
} else if err = browser.OpenURL(authURL); err != nil { } else if err = browser.OpenURL(authURL); err != nil {
log.Warnf("Failed to open browser automatically: %v", err) 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) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
} }
} else { } else {
util.PrintSSHTunnelInstructions(a.CallbackPort) util.PrintSSHTunnelInstructions(callbackPort)
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
} }

View File

@@ -47,6 +47,11 @@ func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
opts = &LoginOptions{} opts = &LoginOptions{}
} }
callbackPort := a.CallbackPort
if opts.CallbackPort > 0 {
callbackPort = opts.CallbackPort
}
pkceCodes, err := codex.GeneratePKCECodes() pkceCodes, err := codex.GeneratePKCECodes()
if err != nil { if err != nil {
return nil, fmt.Errorf("codex pkce generation failed: %w", err) 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) 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 err = oauthServer.Start(); err != nil {
if strings.Contains(err.Error(), "already in use") { if strings.Contains(err.Error(), "already in use") {
return nil, codex.NewAuthenticationError(codex.ErrPortInUse, err) 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") fmt.Println("Opening browser for Codex authentication")
if !browser.IsAvailable() { if !browser.IsAvailable() {
log.Warn("No browser available; please open the URL manually") 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) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
} else if err = browser.OpenURL(authURL); err != nil { } else if err = browser.OpenURL(authURL); err != nil {
log.Warnf("Failed to open browser automatically: %v", err) 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) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
} }
} else { } else {
util.PrintSSHTunnelInstructions(a.CallbackPort) util.PrintSSHTunnelInstructions(callbackPort)
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
} }

View File

@@ -46,6 +46,7 @@ func (a *GeminiAuthenticator) Login(ctx context.Context, cfg *config.Config, opt
geminiAuth := gemini.NewGeminiAuth() geminiAuth := gemini.NewGeminiAuth()
_, err := geminiAuth.GetAuthenticatedClient(ctx, &ts, cfg, &gemini.WebLoginOptions{ _, err := geminiAuth.GetAuthenticatedClient(ctx, &ts, cfg, &gemini.WebLoginOptions{
NoBrowser: opts.NoBrowser, NoBrowser: opts.NoBrowser,
CallbackPort: opts.CallbackPort,
Prompt: opts.Prompt, Prompt: opts.Prompt,
}) })
if err != nil { if err != nil {

View File

@@ -42,9 +42,14 @@ func (a *IFlowAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
opts = &LoginOptions{} opts = &LoginOptions{}
} }
callbackPort := iflow.CallbackPort
if opts.CallbackPort > 0 {
callbackPort = opts.CallbackPort
}
authSvc := iflow.NewIFlowAuth(cfg) authSvc := iflow.NewIFlowAuth(cfg)
oauthServer := iflow.NewOAuthServer(iflow.CallbackPort) oauthServer := iflow.NewOAuthServer(callbackPort)
if err := oauthServer.Start(); err != nil { if err := oauthServer.Start(); err != nil {
if strings.Contains(err.Error(), "already in use") { if strings.Contains(err.Error(), "already in use") {
return nil, fmt.Errorf("iflow authentication server port in use: %w", err) 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) 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 { if !opts.NoBrowser {
fmt.Println("Opening browser for iFlow authentication") fmt.Println("Opening browser for iFlow authentication")
if !browser.IsAvailable() { if !browser.IsAvailable() {
log.Warn("No browser available; please open the URL manually") 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) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
} else if err = browser.OpenURL(authURL); err != nil { } else if err = browser.OpenURL(authURL); err != nil {
log.Warnf("Failed to open browser automatically: %v", err) 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) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
} }
} else { } else {
util.PrintSSHTunnelInstructions(iflow.CallbackPort) util.PrintSSHTunnelInstructions(callbackPort)
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
} }

View File

@@ -16,6 +16,7 @@ var ErrRefreshNotSupported = errors.New("cliproxy auth: refresh not supported")
type LoginOptions struct { type LoginOptions struct {
NoBrowser bool NoBrowser bool
ProjectID string ProjectID string
CallbackPort int
Metadata map[string]string Metadata map[string]string
Prompt func(prompt string) (string, error) Prompt func(prompt string) (string, error)
} }