Merge pull request #2549 from lc6464/gateway-auth-no-browser
feat(auth): add no-browser option for OAuth login
This commit is contained in:
commit
928a27359f
5 changed files with 224 additions and 52 deletions
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`)",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue