-
Notifications
You must be signed in to change notification settings - Fork 212
Description
Bug Report: az login browser flow fails on Linux — IPv4-only listener + false Docker detection
Repository: https://github.com/AzureAD/microsoft-authentication-library-for-python
Related issue: #422 — Support auth code flow in docker container (related but does not cover IPv6 or cgroups v2 false positive)
Prior IPv6 research: PR #284 — MSAL Python 1.7.0 (investigated IPv6 but kept IPv4-only behavior; left a TODO at line 203)
Verified: No existing open or closed issue covers this specific combination of bugs (searched 2026-03-16). Latest stable MSAL 1.35.1 still contains the same code.
Title
az login interactive browser flow fails on Linux: auth callback server only listens on IPv4 while browsers connect via IPv6
Description
The OAuth2 authorization code receiver in msal/oauth2cli/authcode.py starts an HTTP server on 127.0.0.1 (IPv4 loopback only). Modern browsers and HTTP clients resolve localhost to both ::1 (IPv6) and 127.0.0.1 (IPv4), and per Happy Eyeballs (RFC 8305), they attempt IPv6 first.
When the browser completes authentication and Azure AD redirects to http://localhost:<port>/?code=..., the browser connects to [::1]:<port> (IPv6). Since nothing listens on IPv6, the connection fails with ERR_SOCKET_NOT_CONNECTED and the browser does not fall back to IPv4 — because the TCP handshake on IPv6 loopback appears to succeed before being reset (at least on Linux kernel 6.17+).
This results in az login permanently failing in browser flow mode on affected systems.
The problem is compounded by a false positive in _is_inside_docker() on systems using cgroups v2/systemd, which causes the server to bind to 0.0.0.0 (all interfaces) instead of 127.0.0.1 — a security concern per RFC 8252 Section 8.3.
Note: _AuthCodeHttpServer6 (line 167) and the IPv6 selection logic (line 202) already exist in the codebase but are effectively dead code since address is never set to an IPv6 value. The TODO at line 203 acknowledges this gap.
Affected versions
- MSAL Python: 1.35.0b1 (shipped with az CLI 2.84.0) — also confirmed in latest stable 1.35.1
- az CLI: 2.84.0
- Python: 3.13.11 (bundled at
/opt/az/bin/python3) - OS: Linux Mint 22.3 (Ubuntu Noble), kernel 6.17.0-14-generic
- Browser: Opera (Chromium-based), also reproducible with
curl 8.5.0
Root cause
Two issues combine in authcode.py:
Issue 1: _is_inside_docker() false positive on systemd cgroups v2
def _is_inside_docker():
try:
with open("/proc/1/cgroup") as f:
for line in f.readlines():
cgroup_path = line.split(":", 2)[2].strip()
if cgroup_path.strip() != "/":
return True
except IOError:
pass
return os.path.exists("/.dockerenv")On modern systemd systems using cgroups v2, /proc/1/cgroup contains:
0::/init.scope
The cgroup path /init.scope is not /, so _is_inside_docker() returns True. This is a false positive — the system is not running in Docker. This affects any distro that defaults to cgroups v2 (Ubuntu 22.04+, Fedora 38+, Debian 12+, Arch, etc.).
This causes the server to bind to 0.0.0.0 (all interfaces) instead of 127.0.0.1 (loopback), which is a security concern per RFC 8252 Section 8.3.
Issue 2: No IPv6 support for the auth callback server
Line 189:
address = "0.0.0.0" if _is_inside_docker() else "127.0.0.1"Both branches are IPv4-only. The class _AuthCodeHttpServer6 exists (line 167) but is never used in normal operation.
Line 202 has the logic to choose IPv6:
Server = _AuthCodeHttpServer6 if ":" in address else _AuthCodeHttpServerBut since address is always 0.0.0.0 or 127.0.0.1, the IPv6 server class is dead code. The TODO comment at line 203 acknowledges this: # TODO: But, it would treat "localhost" or "" as IPv4.
Steps to reproduce
- Use a Linux system with systemd and cgroups v2 (most modern distros)
- Verify:
cat /proc/1/cgroupshows a path other than/(e.g.,0::/init.scope) - Connect via a VPN that pushes a default route (this affects IPv6 loopback behavior)
- Run
az login --tenant <tenant-id> - Complete authentication in the browser (account picker + MFA)
- Browser redirects to
http://localhost:<port>/?code=... - Browser shows
ERR_SOCKET_NOT_CONNECTED
Verification:
# While az login is waiting, in another terminal:
$ ss -tlnp | grep python
LISTEN 0 5 0.0.0.0:35965 0.0.0.0:* users:(("python3",...))
# ^ IPv4 only — no IPv6 listener
# IPv6 localhost — FAILS:
$ curl -v http://localhost:35965/
* Host localhost:35965 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:35965...
* Connected to localhost (::1) port 35965
* Recv failure: Connection reset by peer
# IPv4 direct — WORKS:
$ curl -v http://127.0.0.1:35965/
* Trying 127.0.0.1:35965...
* Connected to 127.0.0.1 (127.0.0.1) port 35965
< HTTP/1.0 200 OK
< Server: BaseHTTP/0.6 Python/3.13.11Note: gai.conf precedence changes do NOT fix this because browsers implement Happy Eyeballs (RFC 8305) independently of getaddrinfo() ordering — they always try IPv6 first with a head start.
Workaround
Patch line 189 of authcode.py:
# Before:
address = "0.0.0.0" if _is_inside_docker() else "127.0.0.1"
# After:
address = "::1"This forces the server to listen on IPv6 loopback, which is what browsers connect to first via Happy Eyeballs.
Note: the bytecode cache at __pycache__/authcode.cpython-313.pyc must also be deleted after patching. This workaround must be re-applied after az CLI updates.
Suggested fix
Fix 1: Fix Docker detection for cgroups v2
def _is_inside_docker():
# Check for .dockerenv first (most reliable)
if os.path.exists("/.dockerenv"):
return True
try:
with open("/proc/1/cgroup") as f:
for line in f.readlines():
cgroup_path = line.split(":", 2)[2].strip()
# On cgroups v2, Docker containers show paths like /docker/<id>
# or /system.slice/docker-<id>.scope
# Systemd host shows /init.scope — not Docker
if "docker" in cgroup_path or "containerd" in cgroup_path:
return True
except IOError:
pass
return FalseFix 2: Support dual-stack IPv4+IPv6
The auth callback server should listen on both IPv4 and IPv6 loopback. The _AuthCodeHttpServer6 class and the selection logic at line 202 already exist — they just need to be activated. Options:
Option A (minimal): Bind to ::1 (IPv6 loopback) as the default, since all modern browsers try IPv6 first for localhost:
address = "::1"Option B (dual-stack): Use IPv6 dual-stack socket (accepts both IPv4 and IPv6):
address = "::" if _is_inside_docker() else "::1"
# Then set IPV6_V6ONLY=0 on the server socketOption C (most robust): Start two servers (IPv4 + IPv6) — handles all edge cases but more complex.
Impact
This affects any Linux system that:
- Uses systemd with cgroups v2 (most modern distros since ~2022)
- Has a browser that implements Happy Eyeballs (all Chromium/Firefox-based browsers)
- Uses a VPN that may affect IPv6 loopback behavior
The --use-device-code workaround is being disabled by many organizations as a security measure, making the browser flow the only option — and this bug breaks it.