Skip to content

google-auth-oauthlib: run_local_server advertises http://localhost:PORT but binds IPv4 only, so on Windows the OAuth callback can be silently delivered to another process #18296

Description

@yuki2006

Determine this is the right repository

  • I determined this is the correct repository in which to report this bug.

Summary of the issue

Context

On Windows, localhost resolves ::1 before 127.0.0.1. InstalledAppFlow.run_local_server() advertises the name localhost in redirect_uri, but the server it starts is AF_INET only and never listens on ::1.

If any other process is listening on [::]:PORT (the default for Go's net.Listen("tcp", ":PORT")), the browser's redirect goes to that process instead. The authorization code is delivered to it and run_local_server() waits forever.

Expected Behavior:

Either the callback reaches the local server, or the bind fails with a clear error.

Actual Behavior:

The bind succeeds, the browser shows the other process's response, and run_local_server() hangs. Neither side reports an error.

The SO_EXCLUSIVEADDRUSE fix added in #18166 does not prevent this. I measured it - see "Reproduction steps: actual results".

API client name and version

google-auth-oauthlib (vendored in Google Cloud SDK 548.0.0; same code path in current main of packages/google-auth-oauthlib)

Reproduction steps: code

Reproduced through gcloud auth login --launch-browser, which calls run_local_server().

The relevant code, packages/google-auth-oauthlib/google_auth_oauthlib/flow.py:

    def run_local_server(
        self,
        host="localhost",
        ...
        local_server = wsgiref.simple_server.make_server(
            bind_addr or host, port, wsgi_app,
            server_class=_ExclusiveWSGIServer, handler_class=_WSGIRequestHandler,
        )
        ...
        self.redirect_uri = redirect_uri_format.format(host, local_server.server_port)

wsgiref.simple_server.WSGIServer.address_family is socket.AF_INET, and _ExclusiveWSGIServer overrides only server_bind, so the address family is unchanged. host="localhost" resolves to 127.0.0.1 and the server never listens on ::1, while redirect_uri still carries the name localhost.

file: bind_test.py

import socket

PORT = 8085

def try_bind(label, family, addr, exclusive):
    s = socket.socket(family, socket.SOCK_STREAM)
    try:
        if exclusive:
            s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
        s.bind((addr, PORT))
        s.listen(1)
        print(f"[bind OK]   {label}")
    except OSError as e:
        print(f"[bind FAIL] {label}: {e}")
    finally:
        s.close()

try_bind("127.0.0.1  without SO_EXCLUSIVEADDRUSE", socket.AF_INET, "127.0.0.1", False)
try_bind("127.0.0.1  with    SO_EXCLUSIVEADDRUSE", socket.AF_INET, "127.0.0.1", True)
try_bind("::1        without SO_EXCLUSIVEADDRUSE", socket.AF_INET6, "::1", False)

Reproduction steps: supporting files

Any process holding the IPv6 wildcard on the port is enough. A Go program using the default listen address does this:

file: other.go

package main

import "net/http"

func main() {
    // net.Listen("tcp", ":8085") creates a dual-stack IPv6 socket bound to [::]
    http.ListenAndServe(":8085", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte(`{"error":"Unauthorized","success":false}`))
    }))
}

Run it first, then run gcloud auth login --launch-browser (or any run_local_server() call) in another shell.

Reproduction steps: actual results

With a Go service holding [::]:8085:

> Get-NetTCPConnection -LocalPort 8nd the browser still reaches the other process.

The third line matters for the fix: `::1` is bindable even while `[::]` is held.085 -State Listen |
    ForEach-Object { $p = Get-Process -Id $_.OwningProcess; "{0,-12} PID={1,-6} {2}" -f $_.LocalAddress, $_.OwningProcess, $p.ProcessName }

::           PID=11696  main       <- the other service
127.0.0.1    PID=30012  python     <- the callback server

localhost resolution on this machine (hosts file has both entries commented out; Windows resolves it internally):

Name      Type IPAddress
localhost AAAA ::1          <- tried first
localhost A    127.0.0.1

gcloud printed redirect_uri=http://localhost:8085/. After completing the consent screen the browser landed on http://localhost:8085/?state=...&code=... and displayed the other service's body:

{"error":"Unauthorized","success":false}

run_local_server() never returned.

bind_test.py, run while the Go service holds [::]:8085:

[bind OK]   127.0.0.1  without SO_EXCLUSIVEADDRUSE
[bind OK]   127.0.0.1  with    SO_EXCLUSIVEADDRUSE
[bind OK]   ::1        without SO_EXCLUSIVEADDRUSE

On Windows a wildcard [::] bind and a specific-address bind coexist, so SO_EXCLUSIVEADDRUSE sees no conflict. The bind succeeds, the server believes it owns the port, a

Reproduction steps: expected results

Either of these would be correct:

  • The callback reaches the local server and run_local_server() returns credentials, or
  • The bind fails with a clear error, e.g.
[bind FAIL] 127.0.0.1  with SO_EXCLUSIVEADDRUSE: [WinError 10048] Only one usage of each socket address ... is normally permitted

What must not happen is the current outcome: a successful bind, an authorization code handed to an unrelated local process, and a silent hang.

OS & version + platform

Windows 11 Home 10.0.26200.0

Python environment

Python 3.12.10 (bundled with Google Cloud SDK)

Python dependencies

google-auth-oauthlib as vendored in Google Cloud SDK 548.0.0 (lib/third_party/google_auth_oauthlib), not a pip install.

The same code path is present in current main of packages/google-auth-oauthlib.

Additional context

Suggested fix

Any one of these removes the mismatch:

  1. Advertise the loopback IP literal instead of the name - build redirect_uri from 127.0.0.1. RFC 8252 section 7.3 recommends this precisely because name resolution is outside the client's control, and Google's OAuth documentation accepts http://127.0.0.1:<port>.
  2. When the advertised host is localhost, listen on both 127.0.0.1 and ::1. The measurement above shows ::1 is bindable in this situation.
  3. At minimum, make the failure visible: after binding, request the advertised redirect_uri and verify the response came from this server. Today the failure mode is complete silence on both sides, which is what makes it expensive to diagnose.

Workaround for users hitting this

Re-open the redirect URL with the host replaced by the IP literal, keeping the query string intact:

http://127.0.0.1:8085/?state=...&code=...&scope=...

This reaches the IPv4 bind, the code is received and the login completes.

Note for the Cloud SDK

gcloud 548.0.0 vendors a copy of google_auth_oauthlib that predates #18166 - it has no _ExclusiveWSGIServer and no SO_EXCLUSIVEADDRUSE (zero matches in lib/third_party/google_auth_oauthlib/flow.py). Re-vendoring is worth doing on its own merits, but as measured above it would not fix this particular failure.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    triage meI really want to be triaged.type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions