From d600ec03a145cb4d5b8dded6a80b3a81522c3206 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 26 Jul 2026 23:40:45 -0400 Subject: [PATCH] atproto/auth: dynamically select port for oauth callback --- atproto/auth.go | 22 +++++++++++++++++----- internal/app/app.go | 20 ++++++++++++++------ internal/cli/auth_login.go | 17 ++++++++++++++--- internal/cli/root.go | 9 ++++++--- 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/atproto/auth.go b/atproto/auth.go index 9c5dbd5..caf5669 100644 --- a/atproto/auth.go +++ b/atproto/auth.go @@ -165,14 +165,16 @@ func (m *AuthManager) findInsecureFileAccount() (Account, bool, error) { return m.insecureStore.FindAccount() } -func NewAuthManager(callbackURL string) *AuthManager { - return NewAuthManagerWithClient(callbackURL, http.DefaultClient) +func NewAuthManager() *AuthManager { + return NewAuthManagerWithClient(http.DefaultClient) } // NewAuthManagerWithClient creates an AuthManager using httpClient for OAuth -// and authenticated API requests. -func NewAuthManagerWithClient(callbackURL string, httpClient *http.Client) *AuthManager { - config := oauth.NewLocalhostConfig(callbackURL, DefaultScopes) +// and authenticated API requests. The OAuth loopback redirect URI is not known +// until a callback server is bound, so it is configured per login via +// SetCallbackURL before StartLogin. +func NewAuthManagerWithClient(httpClient *http.Client) *AuthManager { + config := oauth.NewLocalhostConfig("", DefaultScopes) config.UserAgent = "tg" store := NewKeyringStore() app := oauth.NewClientApp(&config, store) @@ -188,6 +190,16 @@ func NewAuthManagerWithClient(callbackURL string, httpClient *http.Client) *Auth } } +// SetCallbackURL configures the loopback redirect URI (including a dynamically +// allocated port) for the next OAuth login. Per RFC 8252, tg binds an +// ephemeral port per login, so this must be called with the bound listener's +// address before StartLogin. +func (m *AuthManager) SetCallbackURL(callbackURL string) { + updated := oauth.NewLocalhostConfig(callbackURL, DefaultScopes) + updated.UserAgent = m.app.Config.UserAgent + *m.app.Config = updated +} + // LoginWithPassword authenticates with an atproto app password and stores the // resulting session. When useInsecureFileStore is true the session is written to // plaintext instead of the keyring; this is intended diff --git a/internal/app/app.go b/internal/app/app.go index 0b549cd..dbb83fd 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -32,18 +32,19 @@ const DefaultKnot = knot.DefaultKnot const defaultHTTPTimeout = 30 * time.Second // New returns a Service with production defaults: the default atproto -// identity directory, the given appview host, and an AuthManager using -// oauthCallbackURL for localhost OAuth redirects. -func New(appviewHost, oauthCallbackURL string) *Service { - return NewWithStreams(appviewHost, oauthCallbackURL, os.Stdout, os.Stderr) +// identity directory and the given appview host. The OAuth loopback redirect +// URI is configured per login (see SetOAuthCallbackURL) using an ephemeral +// port allocated when the callback server is bound. +func New(appviewHost string) *Service { + return NewWithStreams(appviewHost, os.Stdout, os.Stderr) } // NewWithStreams creates production dependencies with configurable command // output streams. -func NewWithStreams(appviewHost, oauthCallbackURL string, stdout, stderr io.Writer) *Service { +func NewWithStreams(appviewHost string, stdout, stderr io.Writer) *Service { httpClient := &http.Client{Timeout: defaultHTTPTimeout} resolver := &atproto.Resolver{Directory: identity.DefaultDirectory()} - auth := atproto.NewAuthManagerWithClient(oauthCallbackURL, httpClient) + auth := atproto.NewAuthManagerWithClient(httpClient) return &Service{ resolver: resolver, appview: &tangled.Tangled{ @@ -58,6 +59,13 @@ func NewWithStreams(appviewHost, oauthCallbackURL string, stdout, stderr io.Writ } } +// SetOAuthCallbackURL configures the loopback redirect URI used for the next +// OAuth login. Should be called with the address of a freshly bound local +// listener before StartLogin. +func (s *Service) SetOAuthCallbackURL(callbackURL string) { + s.auth.SetCallbackURL(callbackURL) +} + // SetAccount selects the account used by subsequent service operations. func (s *Service) SetAccount(selector string) { s.auth.SetAccount(selector) diff --git a/internal/cli/auth_login.go b/internal/cli/auth_login.go index 079320e..fa95d6f 100644 --- a/internal/cli/auth_login.go +++ b/internal/cli/auth_login.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net" "net/http" "os" "os/exec" @@ -110,8 +111,19 @@ func loginPassword(args []string, fromStdin bool, stdin io.Reader) (string, bool func runCallbackServer(service *app.Service) (*http.Server, <-chan error, error) { resultChannel := make(chan error, 1) + listener, err := net.Listen("tcp", oauthCallbackHost+":0") + if err != nil { + return nil, nil, fmt.Errorf("bind callback listener: %w", err) + } + + // Register the actual bound port as the OAuth redirect URI before starting + // the login flow. RFC 8252 loopback redirects allow any port; binding an + // ephemeral one avoids clashes with other local services. + callbackURL := "http://" + listener.Addr().String() + oauthCallbackPath + service.SetOAuthCallbackURL(callbackURL) + serveMux := http.NewServeMux() - serveMux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + serveMux.HandleFunc(oauthCallbackPath, func(w http.ResponseWriter, r *http.Request) { if err := service.FinishLogin(r.Context(), r.URL.Query()); err != nil { resultChannel <- err http.Error(w, err.Error(), http.StatusBadRequest) @@ -122,12 +134,11 @@ func runCallbackServer(service *app.Service) (*http.Server, <-chan error, error) }) server := &http.Server{ - Addr: oauthCallbackAddr, Handler: serveMux, } go func() { - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { resultChannel <- fmt.Errorf("callback server: %w", err) } }() diff --git a/internal/cli/root.go b/internal/cli/root.go index 6057975..f10abad 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -12,8 +12,11 @@ import ( ) const ( - oauthCallbackAddr = "127.0.0.1:8095" - oauthCallbackURL = "http://" + oauthCallbackAddr + "/callback" + // oauthCallbackHost is the loopback address the OAuth callback server + // binds. The port is allocated dynamically per login (RFC 8252), not pinned + // here, to avoid clashes with other local services. + oauthCallbackHost = "127.0.0.1" + oauthCallbackPath = "/callback" ) func NewRoot(service *app.Service) *cobra.Command { @@ -61,7 +64,7 @@ func ExecuteWith(arguments []string, input io.Reader, output, errorOutput io.Wri return err } settings := loadConfig(flags, errorOutput) - service := app.NewWithStreams(settings.Appview, oauthCallbackURL, output, errorOutput) + service := app.NewWithStreams(settings.Appview, output, errorOutput) service.SetAccount(settings.Account) root := NewRoot(service) root.SetArgs(arguments) -- 2.51.2