Skip to content

thv llm proxy: browser login fails because a client disconnect kills the OAuth callback listener #6227

Description

@jhrozek

On a cold start, thv llm proxy opens the browser for the OIDC login but the
callback listener is gone by the time the user finishes authenticating, so the
login always fails.

What I saw

I set up the gateway with thv llm setup --lazy, started thv llm proxy, and
pointed a tool at it. The browser opened on the Okta login page as expected. By
the time I had finished entering credentials and MFA, the local callback
listener had already shut down — the redirect landed on nothing.

Why it happens

The proxy fetches a token per request, and on a cold start that fetch runs the
interactive browser flow:

handler()                                     pkg/llm/proxy/proxy.go
  tokenCtx := context.WithTimeout(r.Context(), tokenFetchTimeout)   <-- here
  tokenSource.Token(tokenCtx)
    performBrowserFlow(ctx)                   pkg/auth/tokensource/tokensource.go:389
      oauth.Flow.Start(ctx)                   pkg/auth/oauth/flow.go:191
        go ListenAndServe(":8666")            flow.go:208
        browser.OpenURL(authURL)              flow.go:234
        select {
          case <-tokenChan                    flow.go:250
          case <-ctx.Done()                   flow.go:255  <-- fires
        }
        defer server.Shutdown()               flow.go:216-226

The token-fetch context was derived from the inbound HTTP request's context, so
the login's lifetime was bounded by the calling client's patience. A human takes
30-90 seconds to get through an IdP login; no HTTP client waits that long. When
the client gives up and closes the connection, net/http cancels
r.Context(), flow.go:255 returns, and the deferred Shutdown tears down the
listener while the user is still on the login page.

There are two ways this surfaces:

  1. Single request. The listener is gone when the redirect arrives —
    connection refused.
  2. A retrying client (an agent loop, or a tool that reconnects). The first
    flow dies on disconnect; the retry misses the cache and starts a fresh
    flow on the same pinned callback port with a new state. The user's
    browser tab still carries the old state, so the callback hits the new
    listener and flow.go:322-327 rejects it as invalid state parameter — a
    fatal error that shuts the listener down again.

Either way the user cannot complete a login, and --lazy setup is effectively
unusable through the proxy: the first request is exactly the one that has to
drive a login, and it is also the one guaranteed to outlive its client.

Expected

A client disconnecting should not abort an interactive login that is already in
progress. The login should stay alive long enough for a person to complete it,
and cancelling the proxy itself (Ctrl+C) should still cancel it.

Not covered by this issue

thv llm token invoked as an apiKeyHelper has the same symptom from a
different cause — the client kills the child process, so the listener dies with
it. That needs a separate fix and is tracked separately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions