Compare commits

..
Author SHA1 Message Date
anandh8x daabe11164 fix(providers): send stable session headers to OpenCode 2026-09-15 18:02:59 +05:30
7 changed files with 56 additions and 351 deletions

No files matched your search

+25 -22
View File
@@ -494,28 +494,31 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
err error
}
resultChan := make(chan callbackResult, 1)
// The shared callback server bounds header reads, force-closes a client
// still connected when shutdown runs out of time, and waits for Serve to
// return. The bare http.Server this used to build did none of that, so a
// client stalled mid-header outlived Login (#1028).
server := oauth.StartCallbackServer(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/callback" {
http.NotFound(w, r)
return
}
code, parseErr := flow.parseCallback(r.URL.Query())
if parseErr != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
} else {
_, _ = io.WriteString(w, "Authorization complete. You may close this window.")
}
select {
case resultChan <- callbackResult{code: code, err: parseErr}:
default:
}
}))
defer server.Close()
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/callback" {
http.NotFound(w, r)
return
}
code, parseErr := flow.parseCallback(r.URL.Query())
if parseErr != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
} else {
_, _ = io.WriteString(w, "Authorization complete. You may close this window.")
}
select {
case resultChan <- callbackResult{code: code, err: parseErr}:
default:
}
}),
}
go func() { _ = server.Serve(listener) }()
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
open := options.OpenBrowser
if open == nil {
-85
View File
@@ -6,8 +6,6 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
@@ -670,86 +668,3 @@ func TestFullFlowUsesProtectedResourceDiscovery(t *testing.T) {
t.Fatalf("flow hits = probe:%d resource:%d authorization:%d token:%d", resourceProbeHits.Load(), resourceMetadataHits.Load(), authorizationMetadataHits.Load(), tokenHits.Load())
}
}
// A CLIENT STALLED MID-HEADER MUST NOT OUTLIVE LOGIN (#1028).
//
// Login built a bare http.Server and ended it with Shutdown under a one-second
// deadline, and Shutdown only closes idle connections. A client that has sent
// part of a request header is not idle, so it survived the deadline and kept
// its goroutine, and the Serve goroutine, alive after Login returned.
//
// No sleep is needed to get the stalled connection accepted before Login ends:
// it is dialled before the real callback's connection, Serve accepts in order,
// and Login cannot finish until the real callback has been served.
func TestLoginDoesNotLeaveAStalledCallbackClientOpen(t *testing.T) {
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-final", "token_type": "Bearer", "expires_in": 3600,
})
}))
defer tokenServer.Close()
var stalled net.Conn
opener := func(authURL string) error {
parsed, err := url.Parse(authURL)
if err != nil {
return err
}
redirect, err := url.Parse(parsed.Query().Get("redirect_uri"))
if err != nil {
return err
}
stalled, err = net.Dial("tcp", redirect.Host)
if err != nil {
return err
}
if _, err := fmt.Fprintf(stalled, "GET /callback HTTP/1.1\r\nHost: %s\r\nX-Stall:", redirect.Host); err != nil {
return err
}
cbURL := redirect.String() + "?code=auth-code&state=" + url.QueryEscape(parsed.Query().Get("state"))
go func() {
if resp, err := http.Get(cbURL); err == nil {
resp.Body.Close()
}
}()
return nil
}
_, err := Login(context.Background(), LoginOptions{
ServerName: "demo",
ServerURL: "https://issuer.invalid",
Config: OAuthConfig{
ClientID: "client-123",
AuthorizationEndpoint: "https://issuer.invalid/authorize",
TokenEndpoint: tokenServer.URL,
},
HTTPClient: http.DefaultClient,
OpenBrowser: opener,
Timeout: 5 * time.Second,
Now: time.Now,
})
if err != nil {
t.Fatalf("Login() error = %v", err)
}
if stalled == nil {
t.Fatal("SETUP INVALID: the stalled client never connected, so this proves nothing")
}
defer stalled.Close()
requireCallbackClientClosed(t, stalled)
}
// requireCallbackClientClosed fails unless the server end of conn is already
// closed: a read has to fail with something other than a timeout.
func requireCallbackClientClosed(t *testing.T, conn net.Conn) {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
var one [1]byte
_, err := conn.Read(one[:])
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
t.Fatal("the stalled callback client was still connected after Login returned")
}
if err == nil {
t.Fatal("the stalled callback client received data instead of being closed")
}
}
-70
View File
@@ -1,70 +0,0 @@
package oauth
import (
"context"
"net"
"net/http"
"time"
)
// callbackReadHeaderTimeout bounds how long an accepted connection may take to
// send its request header. A single-use loopback callback server has no reason
// to wait longer, and without a bound a client that stalls mid-header holds the
// connection, and the server goroutine, open indefinitely. A var so the
// package tests can shorten it.
var callbackReadHeaderTimeout = 5 * time.Second
// callbackShutdownBudget is how long Close waits for in-flight requests before
// it force-closes whatever is still open.
const callbackShutdownBudget = time.Second
// CallbackServer is the lifecycle every loopback OAuth callback server needs,
// in one place.
//
// It exists because the same server was written out three times (the shared
// LoopbackListener, MCP's Login and OpenRouter's login) and a fix to one of
// them did not reach the others. http.Server.Shutdown only closes idle
// connections, so a client that has sent part of a request header is active,
// survives the shutdown deadline, and keeps its goroutine and the Serve
// goroutine alive past the end of the flow (#1028). #1051 closed that for
// LoopbackListener; the other two kept the original construction.
type CallbackServer struct {
server *http.Server
done chan struct{}
}
// StartCallbackServer serves handler on listener until Close.
func StartCallbackServer(listener net.Listener, handler http.Handler) *CallbackServer {
return startCallbackServer(listener, handler, nil)
}
// startCallbackServer is StartCallbackServer with a ConnState hook, which the
// package's own tests use to see a connection reach a given state.
func startCallbackServer(listener net.Listener, handler http.Handler, connState func(net.Conn, http.ConnState)) *CallbackServer {
s := &CallbackServer{
server: &http.Server{
Handler: handler,
ReadHeaderTimeout: callbackReadHeaderTimeout,
ConnState: connState,
},
done: make(chan struct{}),
}
go func() {
_ = s.server.Serve(listener)
close(s.done)
}()
return s
}
// Close shuts the server down within callbackShutdownBudget. A connection still
// open when the budget runs out (a stalled request header, say) is force-closed
// rather than leaked, and Close returns only after the Serve goroutine has
// finished, so nothing this server started outlives the call. Idempotent.
func (s *CallbackServer) Close() {
ctx, cancel := context.WithTimeout(context.Background(), callbackShutdownBudget)
defer cancel()
if err := s.server.Shutdown(ctx); err != nil {
_ = s.server.Close()
}
<-s.done
}
-100
View File
@@ -1,100 +0,0 @@
package oauth
import (
"errors"
"fmt"
"net"
"net/http"
"testing"
"time"
)
// startStalledCallbackClient starts a CallbackServer and connects a client that
// sends part of a request header and then stops, which is the shape #1028 is
// about. It returns once the server has ACCEPTED the connection, using the
// ConnState hook rather than a sleep. StateNew, not StateActive: Go reports
// StateActive only after a request read returns, and a stalled header read
// returns only when the header timeout fires, so waiting for StateActive would
// make the setup depend on the very bound the test is about.
func startStalledCallbackClient(t *testing.T) (*CallbackServer, net.Conn) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
accepted := make(chan struct{}, 1)
server := startCallbackServer(listener, http.NotFoundHandler(), func(_ net.Conn, state http.ConnState) {
if state == http.StateNew {
select {
case accepted <- struct{}{}:
default:
}
}
})
conn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
server.Close()
t.Fatal(err)
}
if _, err := fmt.Fprintf(conn, "GET /callback HTTP/1.1\r\nHost: %s\r\nX-Stall:", listener.Addr()); err != nil {
t.Fatal(err)
}
select {
case <-accepted:
case <-time.After(5 * time.Second):
t.Fatal("SETUP INVALID: the server never accepted the stalled client")
}
return server, conn
}
// connClosedWithin reports whether the server end of conn closes before the
// deadline: a read must fail with something other than a timeout.
func connClosedWithin(conn net.Conn, deadline time.Duration) bool {
_ = conn.SetReadDeadline(time.Now().Add(deadline))
var one [1]byte
_, err := conn.Read(one[:])
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return false
}
return err != nil
}
// THE HEADER READ IS BOUNDED EVEN BEFORE CLOSE. The force-close in Close ends a
// stalled client when the flow ends; the header timeout ends it while the flow
// is still waiting for its callback, which can be minutes. Close is not called
// until after the assertion, so this can only pass on ReadHeaderTimeout.
func TestCallbackServerBoundsAStalledHeaderWithoutClose(t *testing.T) {
previous := callbackReadHeaderTimeout
callbackReadHeaderTimeout = 200 * time.Millisecond
defer func() { callbackReadHeaderTimeout = previous }()
server, conn := startStalledCallbackClient(t)
defer server.Close()
defer conn.Close()
if !connClosedWithin(conn, 3*time.Second) {
t.Fatal("a client stalled mid-header was still connected with Close never called; the header read is unbounded")
}
}
// CLOSE RETURNS ONLY AFTER SERVE HAS. Its doc comment promises that nothing the
// server started outlives the call. With no connections open, Shutdown closes
// the listener and returns at once, so this is the case where Close could beat
// the Serve goroutine back; the goroutine closing done is the last thing it
// does, so done has to be closed by the time Close returns.
func TestCallbackServerCloseWaitsForServe(t *testing.T) {
for attempt := 0; attempt < 50; attempt++ {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
server := StartCallbackServer(listener, http.NotFoundHandler())
server.Close()
select {
case <-server.done:
default:
t.Fatalf("attempt %d: Close returned while the Serve goroutine was still running", attempt)
}
}
}
+23 -5
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
)
// LoopbackListener is a single-use loopback HTTP server that captures an OAuth
@@ -18,7 +19,8 @@ type LoopbackListener struct {
listener net.Listener
state string
result chan callbackResult
server *CallbackServer
server *http.Server
done chan struct{}
}
type callbackResult struct {
@@ -56,8 +58,20 @@ func newLoopbackListener(state string, port int, connState func(net.Conn, http.C
listener: ln,
state: state,
result: make(chan callbackResult, 1),
done: make(chan struct{}),
}
l.server = startCallbackServer(ln, http.HandlerFunc(l.handle), connState)
l.server = &http.Server{
Handler: http.HandlerFunc(l.handle),
// This is a single-use, loopback-only callback server: a client that
// never finishes sending its request header must not be able to hold
// the accepted connection (and Close, below) open indefinitely.
ReadHeaderTimeout: 5 * time.Second,
ConnState: connState,
}
go func() {
_ = l.server.Serve(ln)
close(l.done)
}()
return l, nil
}
@@ -108,10 +122,14 @@ func (l *LoopbackListener) Wait(ctx context.Context) (string, error) {
// Close shuts the listener down (bounded), idempotent. If a client leaves a
// connection open past the shutdown deadline (e.g. a stalled request header),
// Close forcibly closes it rather than leaking it past the end of the flow,
// and waits for the Serve goroutine to finish before returning. See
// CallbackServer.Close.
// and waits for the Serve goroutine to finish before returning.
func (l *LoopbackListener) Close() {
l.server.Close()
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := l.server.Shutdown(shutdownCtx); err != nil {
_ = l.server.Close()
}
<-l.done
}
// parseCallback validates the redirect query and returns the authorization code,
+8 -7
View File
@@ -79,11 +79,7 @@ func OpenRouterLogin(ctx context.Context, opts OpenRouterOptions) (string, error
codeCh := make(chan string, 1)
errCh := make(chan error, 1)
// The shared callback server bounds header reads, force-closes a client
// still connected when shutdown runs out of time, and waits for Serve to
// return. The bare http.Server this used to build did none of that, so a
// client stalled mid-header outlived the login (#1028).
server := oauth.StartCallbackServer(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/callback" {
http.NotFound(w, r)
return
@@ -102,8 +98,13 @@ func OpenRouterLogin(ctx context.Context, opts OpenRouterOptions) (string, error
case errCh <- errors.New("provideroauth: callback missing authorization code"):
default:
}
}))
defer server.Close()
})}
go func() { _ = server.Serve(listener) }()
defer func() {
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), time.Second)
defer cancelShutdown()
_ = server.Shutdown(shutdownCtx)
}()
authURL := base + openRouterAuthPath + "?" + url.Values{
"callback_url": {callbackURL},
-62
View File
@@ -4,15 +4,12 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// simulateBrowser returns an OpenBrowser func that authorizes by GETting the
@@ -132,62 +129,3 @@ func TestOpenRouterLoginBrowserErrorPropagates(t *testing.T) {
t.Fatal("a browser-open failure should propagate")
}
}
// A CLIENT STALLED MID-HEADER MUST NOT OUTLIVE THE LOGIN (#1028). Same defect
// as MCP's Login: a bare http.Server ended by Shutdown alone, which leaves a
// connection that has sent part of a request header open after the flow ends.
// The stalled client is dialled before the real callback, so Serve has
// accepted it by the time the login can complete; no sleep is involved.
func TestOpenRouterLoginDoesNotLeaveAStalledCallbackClientOpen(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"key": "sk-or-mock-123"})
}))
defer srv.Close()
var stalled net.Conn
open := func(authURL string) error {
u, err := url.Parse(authURL)
if err != nil {
return err
}
cb, err := url.Parse(u.Query().Get("callback_url"))
if err != nil {
return err
}
stalled, err = net.Dial("tcp", cb.Host)
if err != nil {
return err
}
if _, err := fmt.Fprintf(stalled, "GET /callback HTTP/1.1\r\nHost: %s\r\nX-Stall:", cb.Host); err != nil {
return err
}
resp, err := http.Get(cb.String() + "?code=TESTCODE") //nolint:noctx // test loopback
if err != nil {
return err
}
_ = resp.Body.Close()
return nil
}
if _, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
BaseURL: srv.URL, HTTPClient: srv.Client(), OpenBrowser: open,
}); err != nil {
t.Fatalf("OpenRouterLogin: %v", err)
}
if stalled == nil {
t.Fatal("SETUP INVALID: the stalled client never connected, so this proves nothing")
}
defer stalled.Close()
_ = stalled.SetReadDeadline(time.Now().Add(2 * time.Second))
var one [1]byte
_, err := stalled.Read(one[:])
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
t.Fatal("the stalled callback client was still connected after OpenRouterLogin returned")
}
if err == nil {
t.Fatal("the stalled callback client received data instead of being closed")
}
}