Skip to content

pip --no-input disables credential extraction from redirect URLs, breaking downloads behind auth-redirecting indexes #14181

Description

@noklam

Description

I encountered an unexpected behavior when using pip download using uv run --no-project --with python -m pip. There was a strange behavior where I find out somehow --no-input affect the result of auth.

Contexet

Download/install a package from a private index that answers with a 302 redirect whose Location carries embedded Basic-auth credentials (https://user:token@upstream/...) — a common setup with devpi in front of Artifactory/JFrog, and similar proxy configurations — while passing --no-input (e.g. in CI).

What happened

The download fails with 401 Unauthorized, but only when --no-input is used. The exact same command without --no-input succeeds.

Root cause

When an index issues a cross-origin 302, the vendored requests session strips the Authorization header (should_strip_auth), so the upstream host receives no credentials and returns 401. pip's handle_401 (src/pip/_internal/network/auth.py) is supposed to recover: the embedded credentials are still present in resp.url, and _get_new_credentials() extracts URL-embedded credentials before it ever touches keyring or prompts.

Expected behavior

--no-input shouldn't affect authentication and expect same behavior.

pip version

25.3

Python version

3.13

OS

MacOS/Linux

How to Reproduce

When an index issues a cross-origin 302, the vendored requests session strips the Authorization header (should_strip_auth), so the upstream host receives no credentials and returns 401. pip's handle_401 (src/pip/_internal/network/auth.py) is supposed to recover: the embedded credentials are still present in resp.url, and _get_new_credentials() extracts URL-embedded credentials before it ever touches keyring or prompts.

The problem is that this recovery call is gated behind use_keyring:

# src/pip/_internal/network/auth.py, handle_401()
username, password = None, None

# Query the keyring for credentials:
if self.use_keyring:                       # <-- gate
    username, password = self._get_new_credentials(
        resp.url,
        allow_netrc=False,
        allow_keyring=True,
    )

if not self.prompting and not username and not password:
    return resp                            # <-- returns the 401 unretriedreturns the 401.

--no-input is documented as "Disable prompting for input", but it also silently disables this non-interactive redirect-credential recovery.

Minimal reproduction

This example is generated with AI, but I did test it to confirm the behavior is same as what I experience.

Self-contained, stdlib + pip only (no external index needed). Two local servers stand in for "index → upstream"; only auth.prompting (what --no-input sets) differs between the two attempts:

import base64, threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pip._internal.network.auth import MultiDomainBasicAuth
from pip._vendor import requests

USER, TOKEN = "user", "token"
EXPECTED = "Basic " + base64.b64encode(f"{USER}:{TOKEN}".encode()).decode()

class Upstream(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.headers.get("Authorization") == EXPECTED:
            self.send_response(200); self.end_headers(); self.wfile.write(b"OK")
        else:
            self.send_response(401)
            self.send_header("WWW-Authenticate", 'Basic realm="x"'); self.end_headers()
    def log_message(self, *a): pass

def index(location):
    class Index(BaseHTTPRequestHandler):
        def do_GET(self):
            self.send_response(302); self.send_header("Location", location); self.end_headers()
        def log_message(self, *a): pass
    return Index

def serve(handler):
    srv = HTTPServer(("127.0.0.1", 0), handler)
    threading.Thread(target=srv.serve_forever, daemon=True).start()
    return srv.server_address[1]

def attempt(prompting):
    port_b = serve(Upstream)                                  # upstream (127.0.0.1)
    port_a = serve(index(f"http://{USER}:{TOKEN}@127.0.0.1:{port_b}/pkg"))  # index (localhost)
    url = f"http://localhost:{port_a}/"
    s = requests.Session()
    s.auth = MultiDomainBasicAuth(prompting=prompting, index_urls=[url])
    return s.get(url, timeout=5).status_code
Both attempts return 200. Extracting credentials embedded in a redirect URL requires no user interaction, so --no-input should not disable it.

Output

default (prompting=True) : 200
--no-input(prompting=False): 401

Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    S: needs triageIssues/PRs that need to be triagedtype: bugA confirmed bug or unintended behavior

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions