Merge pull request #2549 from lc6464/gateway-auth-no-browser

feat(auth): add no-browser option for OAuth login
This commit is contained in:
daming大铭 2026-04-17 09:54:03 +08:00 committed by GitHub
commit 928a27359f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 224 additions and 52 deletions

View file

@ -17,24 +17,24 @@ import (
) )
const ( const (
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity" supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity, antigravity"
defaultAnthropicModel = "claude-sonnet-4.6" defaultAnthropicModel = "claude-sonnet-4.6"
) )
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error { func authLoginCmd(provider string, useDeviceCode bool, useOauth bool, noBrowser bool) error {
switch provider { switch provider {
case "openai": case "openai":
return authLoginOpenAI(useDeviceCode) return authLoginOpenAI(useDeviceCode, noBrowser)
case "anthropic": case "anthropic":
return authLoginAnthropic(useOauth) return authLoginAnthropic(useOauth)
case "google-antigravity", "antigravity": case "google-antigravity", "antigravity":
return authLoginGoogleAntigravity() return authLoginGoogleAntigravity(noBrowser)
default: default:
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg) return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
} }
} }
func authLoginOpenAI(useDeviceCode bool) error { func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error {
cfg := auth.OpenAIOAuthConfig() cfg := auth.OpenAIOAuthConfig()
var cred *auth.AuthCredential var cred *auth.AuthCredential
@ -43,7 +43,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
if useDeviceCode { if useDeviceCode {
cred, err = auth.LoginDeviceCode(cfg) cred, err = auth.LoginDeviceCode(cfg)
} else { } else {
cred, err = auth.LoginBrowser(cfg) cred, err = auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser})
} }
if err != nil { if err != nil {
@ -92,10 +92,10 @@ func authLoginOpenAI(useDeviceCode bool) error {
return nil return nil
} }
func authLoginGoogleAntigravity() error { func authLoginGoogleAntigravity(noBrowser bool) error {
cfg := auth.GoogleAntigravityOAuthConfig() cfg := auth.GoogleAntigravityOAuthConfig()
cred, err := auth.LoginBrowser(cfg) cred, err := auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser})
if err != nil { if err != nil {
return fmt.Errorf("login failed: %w", err) return fmt.Errorf("login failed: %w", err)
} }

View file

@ -7,6 +7,7 @@ func newLoginCommand() *cobra.Command {
provider string provider string
useDeviceCode bool useDeviceCode bool
useOauth bool useOauth bool
noBrowser bool
) )
cmd := &cobra.Command{ cmd := &cobra.Command{
@ -14,12 +15,15 @@ func newLoginCommand() *cobra.Command {
Short: "Login via OAuth or paste token", Short: "Login via OAuth or paste token",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
return authLoginCmd(provider, useDeviceCode, useOauth) return authLoginCmd(provider, useDeviceCode, useOauth, noBrowser)
}, },
} }
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)") cmd.Flags().StringVarP(
&provider, "provider", "p", "", "Provider to login with (openai, anthropic, google-antigravity, antigravity)",
)
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)") cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Do not auto-open a browser during OAuth login")
cmd.Flags().BoolVar( cmd.Flags().BoolVar(
&useOauth, "setup-token", false, &useOauth, "setup-token", false,
"Use setup-token flow for Anthropic (from `claude setup-token`)", "Use setup-token flow for Anthropic (from `claude setup-token`)",

View file

@ -18,6 +18,7 @@ func TestNewLoginSubCommand(t *testing.T) {
assert.True(t, cmd.HasFlags()) assert.True(t, cmd.HasFlags())
assert.NotNil(t, cmd.Flags().Lookup("device-code")) assert.NotNil(t, cmd.Flags().Lookup("device-code"))
assert.NotNil(t, cmd.Flags().Lookup("no-browser"))
providerFlag := cmd.Flags().Lookup("provider") providerFlag := cmd.Flags().Lookup("provider")
require.NotNil(t, providerFlag) require.NotNil(t, providerFlag)

View file

@ -30,6 +30,15 @@ type OAuthProviderConfig struct {
Port int Port int
} }
type LoginBrowserOptions struct {
NoBrowser bool
}
var (
openBrowserFunc = OpenBrowser
browserLoginInput io.Reader = os.Stdin
)
func OpenAIOAuthConfig() OAuthProviderConfig { func OpenAIOAuthConfig() OAuthProviderConfig {
return OAuthProviderConfig{ return OAuthProviderConfig{
Issuer: "https://auth.openai.com", Issuer: "https://auth.openai.com",
@ -76,6 +85,10 @@ func GenerateState() (string, error) {
} }
func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
return LoginBrowserWithOptions(cfg, LoginBrowserOptions{})
}
func LoginBrowserWithOptions(cfg OAuthProviderConfig, opts LoginBrowserOptions) (*AuthCredential, error) {
pkce, err := GeneratePKCE() pkce, err := GeneratePKCE()
if err != nil { if err != nil {
return nil, fmt.Errorf("generating PKCE: %w", err) return nil, fmt.Errorf("generating PKCE: %w", err)
@ -86,55 +99,45 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
return nil, fmt.Errorf("generating state: %w", err) return nil, fmt.Errorf("generating state: %w", err)
} }
redirectURI := fmt.Sprintf("http://localhost:%d/auth/callback", cfg.Port) redirectURI := oauthCallbackRedirectURI(cfg.Port)
callbackPort := cfg.Port
var resultCh <-chan callbackResult
authURL := buildAuthorizeURL(cfg, pkce, state, redirectURI) if !opts.NoBrowser {
callbackResultCh := make(chan callbackResult, 1)
resultCh := make(chan callbackResult, 1) listener, actualPort, err := listenOAuthCallback(cfg.Port)
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, "<html><body><h2>Authentication successful!</h2><p>You can close this window.</p></body></html>")
resultCh <- callbackResult{code: code}
})
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.Port))
if err != nil { if err != nil {
return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err) return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err)
} }
server := &http.Server{Handler: mux} redirectURI = oauthCallbackRedirectURI(actualPort)
go server.Serve(listener) callbackPort = actualPort
resultCh = callbackResultCh
server := &http.Server{Handler: oauthCallbackHandler(state, callbackResultCh)}
go func() {
_ = server.Serve(listener)
}()
defer func() { defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
server.Shutdown(ctx) _ = server.Shutdown(ctx)
}() }()
}
authURL := buildAuthorizeURL(cfg, pkce, state, redirectURI)
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL) fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
if err := OpenBrowser(authURL); err != nil { if opts.NoBrowser {
fmt.Println("Browser auto-open disabled. Open the URL manually to continue.")
} else if err := openBrowserFunc(authURL); err != nil {
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL) fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
} }
fmt.Printf( fmt.Printf(
"Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n", "Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n",
cfg.Port, callbackPort,
) )
fmt.Println( fmt.Println(
"please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.", "please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.",
@ -142,11 +145,16 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
fmt.Println("Waiting for authentication (browser or manual paste)...") fmt.Println("Waiting for authentication (browser or manual paste)...")
// Start manual input in a goroutine // Start manual input in a goroutine
manualCh := make(chan string) manualCh := make(chan string, 1)
manualDone := make(chan struct{})
defer close(manualDone)
go func() { go func() {
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(browserLoginInput)
input, _ := reader.ReadString('\n') input, _ := reader.ReadString('\n')
manualCh <- strings.TrimSpace(input) select {
case manualCh <- strings.TrimSpace(input):
case <-manualDone:
}
}() }()
select { select {
@ -176,6 +184,49 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
} }
} }
func oauthCallbackRedirectURI(port int) string {
return fmt.Sprintf("http://localhost:%d/auth/callback", port)
}
func oauthCallbackHandler(state string, resultCh chan<- callbackResult) http.Handler {
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, "<html><body><h2>Authentication successful!</h2><p>You can close this window.</p></body></html>")
resultCh <- callbackResult{code: code}
})
return mux
}
func listenOAuthCallback(port int) (net.Listener, int, error) {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
return nil, 0, err
}
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
if !ok {
_ = listener.Close()
return nil, 0, fmt.Errorf("unexpected listener address type %T", listener.Addr())
}
return listener, tcpAddr.Port, nil
}
type callbackResult struct { type callbackResult struct {
code string code string
err error err error

View file

@ -3,6 +3,7 @@ package auth
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@ -373,3 +374,118 @@ func TestParseDeviceCodeResponseInvalidInterval(t *testing.T) {
t.Fatal("expected error for invalid interval") t.Fatal("expected error for invalid interval")
} }
} }
func TestLoginBrowserWithOptionsNoBrowserDoesNotRequireCallbackPort(t *testing.T) {
server := newMockOAuthTokenServer()
defer server.Close()
reservedListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen() error: %v", err)
}
defer reservedListener.Close()
reservedPort := reservedListener.Addr().(*net.TCPAddr).Port
origOpenBrowserFunc := openBrowserFunc
origBrowserLoginInput := browserLoginInput
t.Cleanup(func() {
openBrowserFunc = origOpenBrowserFunc
browserLoginInput = origBrowserLoginInput
})
var openCalls int
openBrowserFunc = func(string) error {
openCalls++
return nil
}
browserLoginInput = strings.NewReader("manual-code\n")
cfg := OAuthProviderConfig{
Issuer: server.URL,
ClientID: "test-client",
Scopes: "openid",
Port: reservedPort,
}
cred, err := LoginBrowserWithOptions(cfg, LoginBrowserOptions{NoBrowser: true})
if err != nil {
t.Fatalf("LoginBrowserWithOptions() error: %v", err)
}
if openCalls != 0 {
t.Fatalf("openBrowserFunc call count = %d, want 0", openCalls)
}
if cred.AccessToken != "mock-access-token" {
t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "mock-access-token")
}
}
func TestLoginBrowserWithOptionsAutoOpensByDefault(t *testing.T) {
server := newMockOAuthTokenServer()
defer server.Close()
origOpenBrowserFunc := openBrowserFunc
origBrowserLoginInput := browserLoginInput
t.Cleanup(func() {
openBrowserFunc = origOpenBrowserFunc
browserLoginInput = origBrowserLoginInput
})
var (
openCalls int
browserURL string
)
openBrowserFunc = func(url string) error {
openCalls++
browserURL = url
return nil
}
browserLoginInput = strings.NewReader("manual-code\n")
cfg := OAuthProviderConfig{
Issuer: server.URL,
ClientID: "test-client",
Scopes: "openid",
Port: 0,
}
_, err := LoginBrowserWithOptions(cfg, LoginBrowserOptions{})
if err != nil {
t.Fatalf("LoginBrowserWithOptions() error: %v", err)
}
if openCalls != 1 {
t.Fatalf("openBrowserFunc call count = %d, want 1", openCalls)
}
parsedBrowserURL, err := url.Parse(browserURL)
if err != nil {
t.Fatalf("url.Parse(browserURL) error: %v", err)
}
redirectURI, err := url.Parse(parsedBrowserURL.Query().Get("redirect_uri"))
if err != nil {
t.Fatalf("url.Parse(redirectURI) error: %v", err)
}
if redirectURI.Port() == "" {
t.Fatal("redirectURI port is empty")
}
if redirectURI.Port() == "0" {
t.Fatalf("redirectURI port = %q, want dynamically assigned port", redirectURI.Port())
}
}
func newMockOAuthTokenServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/oauth/token" {
http.Error(w, "not found", http.StatusNotFound)
return
}
resp := map[string]any{
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"expires_in": 3600,
}
_ = json.NewEncoder(w).Encode(resp)
}))
}