Skip to content

extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) #351

Description

@abhisheksaxena29

Summary

extract_url_auth in src/manage/urlutils.py returns the userinfo
fields from winhttp_urlsplit without percent-decoding them, and
_basic_auth_header then base64-encodes the literal raw string.
This violates RFC 7617 (HTTP Basic Authentication), which requires
the header to carry the decoded userinfo, and causes
authentication to fail against any private runtime feed whose
username must be percent-encoded in URLs — most commonly an @
in email-format SAML/SSO usernames.

Steps to reproduce

  1. Stand up a private runtime feed (e.g. an internal Artifactory /
    Nexus / S3 generic mirror) protected by HTTP Basic auth, where
    the authenticating user's name contains @
    (e.g. user@example.com — typical for SAML-provisioned users).

  2. Configure pymanager with that feed:

    https://user%40example.com:password@feed.example.com/index.json

    (%40 is the RFC 3986 §3.2.1 percent-encoding of @, required
    because @ is the userinfo/host delimiter and is not in the
    userinfo character set.)

  3. Run pymanager install ... against this feed.

Expected

pymanager sends:

Authorization: Basic <base64("user@example.com:password")>

…and authentication succeeds (this is what every other Python
packaging tool does — see "Prior art" below).

Actual

pymanager sends:

Authorization: Basic <base64("user%40example.com:password")>

The server receives the literal string user%40example.com as the
username, which does not match the stored user, and returns 401.
The unencoded URL form (https://user@example.com:password@host/...)
also fails because WinHttpCrackUrl returns
ERROR_WINHTTP_INVALID_URL (12005) on unencoded @ in userinfo —
which the codebase already catches in sanitise_url. So users
have no working URL form today when the username contains @.

Root cause

In src/manage/urlutils.py:

defextract_url_auth(url):
ifnoturl:
returnurlp=winhttp_urlsplit(url)
user, passw=p[U_USERNAME], p[U_PASSWORD]
ifuserorpassw:
returnuseror"", passwor""returnNone

On the native Windows path, winhttp_urlsplit wraps
WinHttpCrackUrl, which by default returns pwszUserName /
pwszPasswordwithout percent-decoding (no ICU_DECODE
behavior). The values are then passed verbatim to
_basic_auth_header:

def_basic_auth_header(username, password):
frombase64importb64encodepair=f"{username}:{password}".encode("utf-8")
token=b64encode(pair)
return"Basic "+token.decode("ascii")

— no decoding step at any point.
(The pure-Python fallback winhttp_urlsplit happens to work
because urllib.parse.urlsplit().username percent-decodes by
default — so there's also a silent behavior divergence between
the native and fallback paths.)
A subtle confirmation that the maintainers already know native
winhttp_urlsplit returns percent-encoded values: sanitise_url
in the same file has explicit logic for %-prefixed passwords:

pw=p[U_PASSWORD]
ifpwandnot (pw.startswith("%") andpw.startswith("%")):
p[U_PASSWORD] =None

Affected users

Anyone deploying pymanager with a private runtime feed where the
authenticating identity has @ in the username — i.e. virtually
every enterprise that uses SAML/SSO with email-format usernames
and mirrors python.org's index-windows.json through an internal
artifact repository. This is a very common pattern under
PEP 773's "alternative feed"
model.

Suggested fix

One-line change in extract_url_auth to percent-decode user and
password before returning:

defextract_url_auth(url):
ifnoturl:
returnurlp=winhttp_urlsplit(url)
user, passw=p[U_USERNAME], p[U_PASSWORD]
ifuserorpassw:
importurllib.parsereturn (urllib.parse.unquote(useror""),
urllib.parse.unquote(passwor""))
returnNone

This matches what the rest of the Python ecosystem already does
client-side (see prior art).
Happy to send a PR if helpful.

Prior art (this is a well-established convention)

  • pip: pypa/pip#3236
    (2015, fixed in pip 10.0.0) and
    pypa/pip#6775 (2019,
    closing comment by @pradyunsg): "pip 19.2 now requires
    credentials to be URL quoted. Thus, characters like @ and %
    need to be URL quoted (like %40 and %25)."
  • pip docs (current):
    Authentication — Basic HTTP authentication
    explicitly documents %XX encoding for special characters in
    credentials and percent-decodes them client-side.
  • Poetry: python-poetry/poetry#686,
    fixed in #1402.
  • Stack Overflow (70 upvotes, 64k views):
    Escaping username characters in basic auth URLs
    — accepted answer cites RFC 3986 §3.2.1 for %40 as the correct
    form.
  • RFC 3986 §3.2.1 — userinfo grammar requires percent-encoding
    for characters outside unreserved / pct-encoded / sub-delims / ":".
  • RFC 7617 — HTTP Basic Authentication header carries decoded
    user-id and password.

Environment

  • pymanager: [latest]
  • Windows: [latest]
  • Feed server: [Artifactory]

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    , 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
     blocks
    (function() {
    function addCopyButtons() {
    document.querySelectorAll('pre code').forEach(function(codeBlock) {
    if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
    codeBlock.parentElement.setAttribute('data-copy-added', 'true');
    var btn = document.createElement('button');
    btn.textContent = 'Copy';
    btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
    btn.onmouseover = function() { this.style.opacity = '1'; };
    btn.onmouseout = function() { this.style.opacity = '0.7'; };
    btn.onclick = function() {
    navigator.clipboard.writeText(codeBlock.textContent).then(function() {
    btn.textContent = 'Copied!';
    setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
    });
    };
    codeBlock.parentElement.style.position = 'relative';
    codeBlock.parentElement.appendChild(btn);
    });
    }
    addCopyButtons();
    // Re-run on dynamic content
    var observer = new MutationObserver(addCopyButtons);
    observer.observe(document.body, { childList: true, subtree: true });
    })();
    }
    } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
    })();
    (function(){
    try {
    var __m = "github.com";
    var __re = new RegExp('^' + "github\\.com" + '
    extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) · Issue #351 · python/pymanager · GitHub
    Skip to content

    extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) #351

    Description

    @abhisheksaxena29

    Summary

    extract_url_auth in src/manage/urlutils.py returns the userinfo
    fields from winhttp_urlsplit without percent-decoding them, and
    _basic_auth_header then base64-encodes the literal raw string.
    This violates RFC 7617 (HTTP Basic Authentication), which requires
    the header to carry the decoded userinfo, and causes
    authentication to fail against any private runtime feed whose
    username must be percent-encoded in URLs — most commonly an @
    in email-format SAML/SSO usernames.

    Steps to reproduce

    1. Stand up a private runtime feed (e.g. an internal Artifactory /
      Nexus / S3 generic mirror) protected by HTTP Basic auth, where
      the authenticating user's name contains @
      (e.g. user@example.com — typical for SAML-provisioned users).

    2. Configure pymanager with that feed:

      https://user%40example.com:password@feed.example.com/index.json

      (%40 is the RFC 3986 §3.2.1 percent-encoding of @, required
      because @ is the userinfo/host delimiter and is not in the
      userinfo character set.)

    3. Run pymanager install ... against this feed.

    Expected

    pymanager sends:

    Authorization: Basic <base64("user@example.com:password")>

    …and authentication succeeds (this is what every other Python
    packaging tool does — see "Prior art" below).

    Actual

    pymanager sends:

    Authorization: Basic <base64("user%40example.com:password")>

    The server receives the literal string user%40example.com as the
    username, which does not match the stored user, and returns 401.
    The unencoded URL form (https://user@example.com:password@host/...)
    also fails because WinHttpCrackUrl returns
    ERROR_WINHTTP_INVALID_URL (12005) on unencoded @ in userinfo —
    which the codebase already catches in sanitise_url. So users
    have no working URL form today when the username contains @.

    Root cause

    In src/manage/urlutils.py:

    defextract_url_auth(url):
    ifnoturl:
    returnurlp=winhttp_urlsplit(url)
    user, passw=p[U_USERNAME], p[U_PASSWORD]
    ifuserorpassw:
    returnuseror"", passwor""returnNone

    On the native Windows path, winhttp_urlsplit wraps
    WinHttpCrackUrl, which by default returns pwszUserName /
    pwszPasswordwithout percent-decoding (no ICU_DECODE
    behavior). The values are then passed verbatim to
    _basic_auth_header:

    def_basic_auth_header(username, password):
    frombase64importb64encodepair=f"{username}:{password}".encode("utf-8")
    token=b64encode(pair)
    return"Basic "+token.decode("ascii")

    — no decoding step at any point.
    (The pure-Python fallback winhttp_urlsplit happens to work
    because urllib.parse.urlsplit().username percent-decodes by
    default — so there's also a silent behavior divergence between
    the native and fallback paths.)
    A subtle confirmation that the maintainers already know native
    winhttp_urlsplit returns percent-encoded values: sanitise_url
    in the same file has explicit logic for %-prefixed passwords:

    pw=p[U_PASSWORD]
    ifpwandnot (pw.startswith("%") andpw.startswith("%")):
    p[U_PASSWORD] =None

    Affected users

    Anyone deploying pymanager with a private runtime feed where the
    authenticating identity has @ in the username — i.e. virtually
    every enterprise that uses SAML/SSO with email-format usernames
    and mirrors python.org's index-windows.json through an internal
    artifact repository. This is a very common pattern under
    PEP 773's "alternative feed"
    model.

    Suggested fix

    One-line change in extract_url_auth to percent-decode user and
    password before returning:

    defextract_url_auth(url):
    ifnoturl:
    returnurlp=winhttp_urlsplit(url)
    user, passw=p[U_USERNAME], p[U_PASSWORD]
    ifuserorpassw:
    importurllib.parsereturn (urllib.parse.unquote(useror""),
    urllib.parse.unquote(passwor""))
    returnNone

    This matches what the rest of the Python ecosystem already does
    client-side (see prior art).
    Happy to send a PR if helpful.

    Prior art (this is a well-established convention)

    • pip: pypa/pip#3236
      (2015, fixed in pip 10.0.0) and
      pypa/pip#6775 (2019,
      closing comment by @pradyunsg): "pip 19.2 now requires
      credentials to be URL quoted. Thus, characters like @ and %
      need to be URL quoted (like %40 and %25)."
    • pip docs (current):
      Authentication — Basic HTTP authentication
      explicitly documents %XX encoding for special characters in
      credentials and percent-decodes them client-side.
    • Poetry: python-poetry/poetry#686,
      fixed in #1402.
    • Stack Overflow (70 upvotes, 64k views):
      Escaping username characters in basic auth URLs
      — accepted answer cites RFC 3986 §3.2.1 for %40 as the correct
      form.
    • RFC 3986 §3.2.1 — userinfo grammar requires percent-encoding
      for characters outside unreserved / pct-encoded / sub-delims / ":".
    • RFC 7617 — HTTP Basic Authentication header carries decoded
      user-id and password.

    Environment

    • pymanager: [latest]
    • Windows: [latest]
    • Feed server: [Artifactory]

    Metadata

    Metadata

    Assignees

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) · Issue #351 · python/pymanager · GitHub
      Skip to content

      extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) #351

      Description

      @abhisheksaxena29

      Summary

      extract_url_auth in src/manage/urlutils.py returns the userinfo
      fields from winhttp_urlsplit without percent-decoding them, and
      _basic_auth_header then base64-encodes the literal raw string.
      This violates RFC 7617 (HTTP Basic Authentication), which requires
      the header to carry the decoded userinfo, and causes
      authentication to fail against any private runtime feed whose
      username must be percent-encoded in URLs — most commonly an @
      in email-format SAML/SSO usernames.

      Steps to reproduce

      1. Stand up a private runtime feed (e.g. an internal Artifactory /
        Nexus / S3 generic mirror) protected by HTTP Basic auth, where
        the authenticating user's name contains @
        (e.g. user@example.com — typical for SAML-provisioned users).

      2. Configure pymanager with that feed:

        https://user%40example.com:password@feed.example.com/index.json

        (%40 is the RFC 3986 §3.2.1 percent-encoding of @, required
        because @ is the userinfo/host delimiter and is not in the
        userinfo character set.)

      3. Run pymanager install ... against this feed.

      Expected

      pymanager sends:

      Authorization: Basic <base64("user@example.com:password")>

      …and authentication succeeds (this is what every other Python
      packaging tool does — see "Prior art" below).

      Actual

      pymanager sends:

      Authorization: Basic <base64("user%40example.com:password")>

      The server receives the literal string user%40example.com as the
      username, which does not match the stored user, and returns 401.
      The unencoded URL form (https://user@example.com:password@host/...)
      also fails because WinHttpCrackUrl returns
      ERROR_WINHTTP_INVALID_URL (12005) on unencoded @ in userinfo —
      which the codebase already catches in sanitise_url. So users
      have no working URL form today when the username contains @.

      Root cause

      In src/manage/urlutils.py:

      defextract_url_auth(url):
      ifnoturl:
      returnurlp=winhttp_urlsplit(url)
      user, passw=p[U_USERNAME], p[U_PASSWORD]
      ifuserorpassw:
      returnuseror"", passwor""returnNone

      On the native Windows path, winhttp_urlsplit wraps
      WinHttpCrackUrl, which by default returns pwszUserName /
      pwszPasswordwithout percent-decoding (no ICU_DECODE
      behavior). The values are then passed verbatim to
      _basic_auth_header:

      def_basic_auth_header(username, password):
      frombase64importb64encodepair=f"{username}:{password}".encode("utf-8")
      token=b64encode(pair)
      return"Basic "+token.decode("ascii")

      — no decoding step at any point.
      (The pure-Python fallback winhttp_urlsplit happens to work
      because urllib.parse.urlsplit().username percent-decodes by
      default — so there's also a silent behavior divergence between
      the native and fallback paths.)
      A subtle confirmation that the maintainers already know native
      winhttp_urlsplit returns percent-encoded values: sanitise_url
      in the same file has explicit logic for %-prefixed passwords:

      pw=p[U_PASSWORD]
      ifpwandnot (pw.startswith("%") andpw.startswith("%")):
      p[U_PASSWORD] =None

      Affected users

      Anyone deploying pymanager with a private runtime feed where the
      authenticating identity has @ in the username — i.e. virtually
      every enterprise that uses SAML/SSO with email-format usernames
      and mirrors python.org's index-windows.json through an internal
      artifact repository. This is a very common pattern under
      PEP 773's "alternative feed"
      model.

      Suggested fix

      One-line change in extract_url_auth to percent-decode user and
      password before returning:

      defextract_url_auth(url):
      ifnoturl:
      returnurlp=winhttp_urlsplit(url)
      user, passw=p[U_USERNAME], p[U_PASSWORD]
      ifuserorpassw:
      importurllib.parsereturn (urllib.parse.unquote(useror""),
      urllib.parse.unquote(passwor""))
      returnNone

      This matches what the rest of the Python ecosystem already does
      client-side (see prior art).
      Happy to send a PR if helpful.

      Prior art (this is a well-established convention)

      • pip: pypa/pip#3236
        (2015, fixed in pip 10.0.0) and
        pypa/pip#6775 (2019,
        closing comment by @pradyunsg): "pip 19.2 now requires
        credentials to be URL quoted. Thus, characters like @ and %
        need to be URL quoted (like %40 and %25)."
      • pip docs (current):
        Authentication — Basic HTTP authentication
        explicitly documents %XX encoding for special characters in
        credentials and percent-decodes them client-side.
      • Poetry: python-poetry/poetry#686,
        fixed in #1402.
      • Stack Overflow (70 upvotes, 64k views):
        Escaping username characters in basic auth URLs
        — accepted answer cites RFC 3986 §3.2.1 for %40 as the correct
        form.
      • RFC 3986 §3.2.1 — userinfo grammar requires percent-encoding
        for characters outside unreserved / pct-encoded / sub-delims / ":".
      • RFC 7617 — HTTP Basic Authentication header carries decoded
        user-id and password.

      Environment

      • pymanager: [latest]
      • Windows: [latest]
      • Feed server: [Artifactory]

      Metadata

      Metadata

      Assignees

      Labels

      bugSomething isn't working

      Projects

      No projects

        Milestone

        No milestone

        Relationships

        None yet

        Development

        No branches or pull requests

        Issue actions

        , 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) · Issue #351 · python/pymanager · GitHub
        Skip to content

        extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) #351

        Description

        @abhisheksaxena29

        Summary

        extract_url_auth in src/manage/urlutils.py returns the userinfo
        fields from winhttp_urlsplit without percent-decoding them, and
        _basic_auth_header then base64-encodes the literal raw string.
        This violates RFC 7617 (HTTP Basic Authentication), which requires
        the header to carry the decoded userinfo, and causes
        authentication to fail against any private runtime feed whose
        username must be percent-encoded in URLs — most commonly an @
        in email-format SAML/SSO usernames.

        Steps to reproduce

        1. Stand up a private runtime feed (e.g. an internal Artifactory /
          Nexus / S3 generic mirror) protected by HTTP Basic auth, where
          the authenticating user's name contains @
          (e.g. user@example.com — typical for SAML-provisioned users).

        2. Configure pymanager with that feed:

          https://user%40example.com:password@feed.example.com/index.json

          (%40 is the RFC 3986 §3.2.1 percent-encoding of @, required
          because @ is the userinfo/host delimiter and is not in the
          userinfo character set.)

        3. Run pymanager install ... against this feed.

        Expected

        pymanager sends:

        Authorization: Basic <base64("user@example.com:password")>

        …and authentication succeeds (this is what every other Python
        packaging tool does — see "Prior art" below).

        Actual

        pymanager sends:

        Authorization: Basic <base64("user%40example.com:password")>

        The server receives the literal string user%40example.com as the
        username, which does not match the stored user, and returns 401.
        The unencoded URL form (https://user@example.com:password@host/...)
        also fails because WinHttpCrackUrl returns
        ERROR_WINHTTP_INVALID_URL (12005) on unencoded @ in userinfo —
        which the codebase already catches in sanitise_url. So users
        have no working URL form today when the username contains @.

        Root cause

        In src/manage/urlutils.py:

        defextract_url_auth(url):
        ifnoturl:
        returnurlp=winhttp_urlsplit(url)
        user, passw=p[U_USERNAME], p[U_PASSWORD]
        ifuserorpassw:
        returnuseror"", passwor""returnNone

        On the native Windows path, winhttp_urlsplit wraps
        WinHttpCrackUrl, which by default returns pwszUserName /
        pwszPasswordwithout percent-decoding (no ICU_DECODE
        behavior). The values are then passed verbatim to
        _basic_auth_header:

        def_basic_auth_header(username, password):
        frombase64importb64encodepair=f"{username}:{password}".encode("utf-8")
        token=b64encode(pair)
        return"Basic "+token.decode("ascii")

        — no decoding step at any point.
        (The pure-Python fallback winhttp_urlsplit happens to work
        because urllib.parse.urlsplit().username percent-decodes by
        default — so there's also a silent behavior divergence between
        the native and fallback paths.)
        A subtle confirmation that the maintainers already know native
        winhttp_urlsplit returns percent-encoded values: sanitise_url
        in the same file has explicit logic for %-prefixed passwords:

        pw=p[U_PASSWORD]
        ifpwandnot (pw.startswith("%") andpw.startswith("%")):
        p[U_PASSWORD] =None

        Affected users

        Anyone deploying pymanager with a private runtime feed where the
        authenticating identity has @ in the username — i.e. virtually
        every enterprise that uses SAML/SSO with email-format usernames
        and mirrors python.org's index-windows.json through an internal
        artifact repository. This is a very common pattern under
        PEP 773's "alternative feed"
        model.

        Suggested fix

        One-line change in extract_url_auth to percent-decode user and
        password before returning:

        defextract_url_auth(url):
        ifnoturl:
        returnurlp=winhttp_urlsplit(url)
        user, passw=p[U_USERNAME], p[U_PASSWORD]
        ifuserorpassw:
        importurllib.parsereturn (urllib.parse.unquote(useror""),
        urllib.parse.unquote(passwor""))
        returnNone

        This matches what the rest of the Python ecosystem already does
        client-side (see prior art).
        Happy to send a PR if helpful.

        Prior art (this is a well-established convention)

        • pip: pypa/pip#3236
          (2015, fixed in pip 10.0.0) and
          pypa/pip#6775 (2019,
          closing comment by @pradyunsg): "pip 19.2 now requires
          credentials to be URL quoted. Thus, characters like @ and %
          need to be URL quoted (like %40 and %25)."
        • pip docs (current):
          Authentication — Basic HTTP authentication
          explicitly documents %XX encoding for special characters in
          credentials and percent-decodes them client-side.
        • Poetry: python-poetry/poetry#686,
          fixed in #1402.
        • Stack Overflow (70 upvotes, 64k views):
          Escaping username characters in basic auth URLs
          — accepted answer cites RFC 3986 §3.2.1 for %40 as the correct
          form.
        • RFC 3986 §3.2.1 — userinfo grammar requires percent-encoding
          for characters outside unreserved / pct-encoded / sub-delims / ":".
        • RFC 7617 — HTTP Basic Authentication header carries decoded
          user-id and password.

        Environment

        • pymanager: [latest]
        • Windows: [latest]
        • Feed server: [Artifactory]

        Metadata

        Metadata

        Assignees

        Labels

        bugSomething isn't working

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) · Issue #351 · python/pymanager · GitHub
          Skip to content

          extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) #351

          Description

          @abhisheksaxena29

          Summary

          extract_url_auth in src/manage/urlutils.py returns the userinfo
          fields from winhttp_urlsplit without percent-decoding them, and
          _basic_auth_header then base64-encodes the literal raw string.
          This violates RFC 7617 (HTTP Basic Authentication), which requires
          the header to carry the decoded userinfo, and causes
          authentication to fail against any private runtime feed whose
          username must be percent-encoded in URLs — most commonly an @
          in email-format SAML/SSO usernames.

          Steps to reproduce

          1. Stand up a private runtime feed (e.g. an internal Artifactory /
            Nexus / S3 generic mirror) protected by HTTP Basic auth, where
            the authenticating user's name contains @
            (e.g. user@example.com — typical for SAML-provisioned users).

          2. Configure pymanager with that feed:

            https://user%40example.com:password@feed.example.com/index.json

            (%40 is the RFC 3986 §3.2.1 percent-encoding of @, required
            because @ is the userinfo/host delimiter and is not in the
            userinfo character set.)

          3. Run pymanager install ... against this feed.

          Expected

          pymanager sends:

          Authorization: Basic <base64("user@example.com:password")>

          …and authentication succeeds (this is what every other Python
          packaging tool does — see "Prior art" below).

          Actual

          pymanager sends:

          Authorization: Basic <base64("user%40example.com:password")>

          The server receives the literal string user%40example.com as the
          username, which does not match the stored user, and returns 401.
          The unencoded URL form (https://user@example.com:password@host/...)
          also fails because WinHttpCrackUrl returns
          ERROR_WINHTTP_INVALID_URL (12005) on unencoded @ in userinfo —
          which the codebase already catches in sanitise_url. So users
          have no working URL form today when the username contains @.

          Root cause

          In src/manage/urlutils.py:

          defextract_url_auth(url):
          ifnoturl:
          returnurlp=winhttp_urlsplit(url)
          user, passw=p[U_USERNAME], p[U_PASSWORD]
          ifuserorpassw:
          returnuseror"", passwor""returnNone

          On the native Windows path, winhttp_urlsplit wraps
          WinHttpCrackUrl, which by default returns pwszUserName /
          pwszPasswordwithout percent-decoding (no ICU_DECODE
          behavior). The values are then passed verbatim to
          _basic_auth_header:

          def_basic_auth_header(username, password):
          frombase64importb64encodepair=f"{username}:{password}".encode("utf-8")
          token=b64encode(pair)
          return"Basic "+token.decode("ascii")

          — no decoding step at any point.
          (The pure-Python fallback winhttp_urlsplit happens to work
          because urllib.parse.urlsplit().username percent-decodes by
          default — so there's also a silent behavior divergence between
          the native and fallback paths.)
          A subtle confirmation that the maintainers already know native
          winhttp_urlsplit returns percent-encoded values: sanitise_url
          in the same file has explicit logic for %-prefixed passwords:

          pw=p[U_PASSWORD]
          ifpwandnot (pw.startswith("%") andpw.startswith("%")):
          p[U_PASSWORD] =None

          Affected users

          Anyone deploying pymanager with a private runtime feed where the
          authenticating identity has @ in the username — i.e. virtually
          every enterprise that uses SAML/SSO with email-format usernames
          and mirrors python.org's index-windows.json through an internal
          artifact repository. This is a very common pattern under
          PEP 773's "alternative feed"
          model.

          Suggested fix

          One-line change in extract_url_auth to percent-decode user and
          password before returning:

          defextract_url_auth(url):
          ifnoturl:
          returnurlp=winhttp_urlsplit(url)
          user, passw=p[U_USERNAME], p[U_PASSWORD]
          ifuserorpassw:
          importurllib.parsereturn (urllib.parse.unquote(useror""),
          urllib.parse.unquote(passwor""))
          returnNone

          This matches what the rest of the Python ecosystem already does
          client-side (see prior art).
          Happy to send a PR if helpful.

          Prior art (this is a well-established convention)

          • pip: pypa/pip#3236
            (2015, fixed in pip 10.0.0) and
            pypa/pip#6775 (2019,
            closing comment by @pradyunsg): "pip 19.2 now requires
            credentials to be URL quoted. Thus, characters like @ and %
            need to be URL quoted (like %40 and %25)."
          • pip docs (current):
            Authentication — Basic HTTP authentication
            explicitly documents %XX encoding for special characters in
            credentials and percent-decodes them client-side.
          • Poetry: python-poetry/poetry#686,
            fixed in #1402.
          • Stack Overflow (70 upvotes, 64k views):
            Escaping username characters in basic auth URLs
            — accepted answer cites RFC 3986 §3.2.1 for %40 as the correct
            form.
          • RFC 3986 §3.2.1 — userinfo grammar requires percent-encoding
            for characters outside unreserved / pct-encoded / sub-delims / ":".
          • RFC 7617 — HTTP Basic Authentication header carries decoded
            user-id and password.

          Environment

          • pymanager: [latest]
          • Windows: [latest]
          • Feed server: [Artifactory]

          Metadata

          Metadata

          Assignees

          Labels

          bugSomething isn't working

          Projects

          No projects

            Milestone

            No milestone

            Relationships

            None yet

            Development

            No branches or pull requests

            Issue actions

            , 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) · Issue #351 · python/pymanager · GitHub
            Skip to content

            extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) #351

            Description

            @abhisheksaxena29

            Summary

            extract_url_auth in src/manage/urlutils.py returns the userinfo
            fields from winhttp_urlsplit without percent-decoding them, and
            _basic_auth_header then base64-encodes the literal raw string.
            This violates RFC 7617 (HTTP Basic Authentication), which requires
            the header to carry the decoded userinfo, and causes
            authentication to fail against any private runtime feed whose
            username must be percent-encoded in URLs — most commonly an @
            in email-format SAML/SSO usernames.

            Steps to reproduce

            1. Stand up a private runtime feed (e.g. an internal Artifactory /
              Nexus / S3 generic mirror) protected by HTTP Basic auth, where
              the authenticating user's name contains @
              (e.g. user@example.com — typical for SAML-provisioned users).

            2. Configure pymanager with that feed:

              https://user%40example.com:password@feed.example.com/index.json

              (%40 is the RFC 3986 §3.2.1 percent-encoding of @, required
              because @ is the userinfo/host delimiter and is not in the
              userinfo character set.)

            3. Run pymanager install ... against this feed.

            Expected

            pymanager sends:

            Authorization: Basic <base64("user@example.com:password")>

            …and authentication succeeds (this is what every other Python
            packaging tool does — see "Prior art" below).

            Actual

            pymanager sends:

            Authorization: Basic <base64("user%40example.com:password")>

            The server receives the literal string user%40example.com as the
            username, which does not match the stored user, and returns 401.
            The unencoded URL form (https://user@example.com:password@host/...)
            also fails because WinHttpCrackUrl returns
            ERROR_WINHTTP_INVALID_URL (12005) on unencoded @ in userinfo —
            which the codebase already catches in sanitise_url. So users
            have no working URL form today when the username contains @.

            Root cause

            In src/manage/urlutils.py:

            defextract_url_auth(url):
            ifnoturl:
            returnurlp=winhttp_urlsplit(url)
            user, passw=p[U_USERNAME], p[U_PASSWORD]
            ifuserorpassw:
            returnuseror"", passwor""returnNone

            On the native Windows path, winhttp_urlsplit wraps
            WinHttpCrackUrl, which by default returns pwszUserName /
            pwszPasswordwithout percent-decoding (no ICU_DECODE
            behavior). The values are then passed verbatim to
            _basic_auth_header:

            def_basic_auth_header(username, password):
            frombase64importb64encodepair=f"{username}:{password}".encode("utf-8")
            token=b64encode(pair)
            return"Basic "+token.decode("ascii")

            — no decoding step at any point.
            (The pure-Python fallback winhttp_urlsplit happens to work
            because urllib.parse.urlsplit().username percent-decodes by
            default — so there's also a silent behavior divergence between
            the native and fallback paths.)
            A subtle confirmation that the maintainers already know native
            winhttp_urlsplit returns percent-encoded values: sanitise_url
            in the same file has explicit logic for %-prefixed passwords:

            pw=p[U_PASSWORD]
            ifpwandnot (pw.startswith("%") andpw.startswith("%")):
            p[U_PASSWORD] =None

            Affected users

            Anyone deploying pymanager with a private runtime feed where the
            authenticating identity has @ in the username — i.e. virtually
            every enterprise that uses SAML/SSO with email-format usernames
            and mirrors python.org's index-windows.json through an internal
            artifact repository. This is a very common pattern under
            PEP 773's "alternative feed"
            model.

            Suggested fix

            One-line change in extract_url_auth to percent-decode user and
            password before returning:

            defextract_url_auth(url):
            ifnoturl:
            returnurlp=winhttp_urlsplit(url)
            user, passw=p[U_USERNAME], p[U_PASSWORD]
            ifuserorpassw:
            importurllib.parsereturn (urllib.parse.unquote(useror""),
            urllib.parse.unquote(passwor""))
            returnNone

            This matches what the rest of the Python ecosystem already does
            client-side (see prior art).
            Happy to send a PR if helpful.

            Prior art (this is a well-established convention)

            • pip: pypa/pip#3236
              (2015, fixed in pip 10.0.0) and
              pypa/pip#6775 (2019,
              closing comment by @pradyunsg): "pip 19.2 now requires
              credentials to be URL quoted. Thus, characters like @ and %
              need to be URL quoted (like %40 and %25)."
            • pip docs (current):
              Authentication — Basic HTTP authentication
              explicitly documents %XX encoding for special characters in
              credentials and percent-decodes them client-side.
            • Poetry: python-poetry/poetry#686,
              fixed in #1402.
            • Stack Overflow (70 upvotes, 64k views):
              Escaping username characters in basic auth URLs
              — accepted answer cites RFC 3986 §3.2.1 for %40 as the correct
              form.
            • RFC 3986 §3.2.1 — userinfo grammar requires percent-encoding
              for characters outside unreserved / pct-encoded / sub-delims / ":".
            • RFC 7617 — HTTP Basic Authentication header carries decoded
              user-id and password.

            Environment

            • pymanager: [latest]
            • Windows: [latest]
            • Feed server: [Artifactory]

            Metadata

            Metadata

            Assignees

            Labels

            bugSomething isn't working

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) · Issue #351 · python/pymanager · GitHub
              Skip to content

              extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) #351

              Description

              @abhisheksaxena29

              Summary

              extract_url_auth in src/manage/urlutils.py returns the userinfo
              fields from winhttp_urlsplit without percent-decoding them, and
              _basic_auth_header then base64-encodes the literal raw string.
              This violates RFC 7617 (HTTP Basic Authentication), which requires
              the header to carry the decoded userinfo, and causes
              authentication to fail against any private runtime feed whose
              username must be percent-encoded in URLs — most commonly an @
              in email-format SAML/SSO usernames.

              Steps to reproduce

              1. Stand up a private runtime feed (e.g. an internal Artifactory /
                Nexus / S3 generic mirror) protected by HTTP Basic auth, where
                the authenticating user's name contains @
                (e.g. user@example.com — typical for SAML-provisioned users).

              2. Configure pymanager with that feed:

                https://user%40example.com:password@feed.example.com/index.json

                (%40 is the RFC 3986 §3.2.1 percent-encoding of @, required
                because @ is the userinfo/host delimiter and is not in the
                userinfo character set.)

              3. Run pymanager install ... against this feed.

              Expected

              pymanager sends:

              Authorization: Basic <base64("user@example.com:password")>

              …and authentication succeeds (this is what every other Python
              packaging tool does — see "Prior art" below).

              Actual

              pymanager sends:

              Authorization: Basic <base64("user%40example.com:password")>

              The server receives the literal string user%40example.com as the
              username, which does not match the stored user, and returns 401.
              The unencoded URL form (https://user@example.com:password@host/...)
              also fails because WinHttpCrackUrl returns
              ERROR_WINHTTP_INVALID_URL (12005) on unencoded @ in userinfo —
              which the codebase already catches in sanitise_url. So users
              have no working URL form today when the username contains @.

              Root cause

              In src/manage/urlutils.py:

              defextract_url_auth(url):
              ifnoturl:
              returnurlp=winhttp_urlsplit(url)
              user, passw=p[U_USERNAME], p[U_PASSWORD]
              ifuserorpassw:
              returnuseror"", passwor""returnNone

              On the native Windows path, winhttp_urlsplit wraps
              WinHttpCrackUrl, which by default returns pwszUserName /
              pwszPasswordwithout percent-decoding (no ICU_DECODE
              behavior). The values are then passed verbatim to
              _basic_auth_header:

              def_basic_auth_header(username, password):
              frombase64importb64encodepair=f"{username}:{password}".encode("utf-8")
              token=b64encode(pair)
              return"Basic "+token.decode("ascii")

              — no decoding step at any point.
              (The pure-Python fallback winhttp_urlsplit happens to work
              because urllib.parse.urlsplit().username percent-decodes by
              default — so there's also a silent behavior divergence between
              the native and fallback paths.)
              A subtle confirmation that the maintainers already know native
              winhttp_urlsplit returns percent-encoded values: sanitise_url
              in the same file has explicit logic for %-prefixed passwords:

              pw=p[U_PASSWORD]
              ifpwandnot (pw.startswith("%") andpw.startswith("%")):
              p[U_PASSWORD] =None

              Affected users

              Anyone deploying pymanager with a private runtime feed where the
              authenticating identity has @ in the username — i.e. virtually
              every enterprise that uses SAML/SSO with email-format usernames
              and mirrors python.org's index-windows.json through an internal
              artifact repository. This is a very common pattern under
              PEP 773's "alternative feed"
              model.

              Suggested fix

              One-line change in extract_url_auth to percent-decode user and
              password before returning:

              defextract_url_auth(url):
              ifnoturl:
              returnurlp=winhttp_urlsplit(url)
              user, passw=p[U_USERNAME], p[U_PASSWORD]
              ifuserorpassw:
              importurllib.parsereturn (urllib.parse.unquote(useror""),
              urllib.parse.unquote(passwor""))
              returnNone

              This matches what the rest of the Python ecosystem already does
              client-side (see prior art).
              Happy to send a PR if helpful.

              Prior art (this is a well-established convention)

              • pip: pypa/pip#3236
                (2015, fixed in pip 10.0.0) and
                pypa/pip#6775 (2019,
                closing comment by @pradyunsg): "pip 19.2 now requires
                credentials to be URL quoted. Thus, characters like @ and %
                need to be URL quoted (like %40 and %25)."
              • pip docs (current):
                Authentication — Basic HTTP authentication
                explicitly documents %XX encoding for special characters in
                credentials and percent-decodes them client-side.
              • Poetry: python-poetry/poetry#686,
                fixed in #1402.
              • Stack Overflow (70 upvotes, 64k views):
                Escaping username characters in basic auth URLs
                — accepted answer cites RFC 3986 §3.2.1 for %40 as the correct
                form.
              • RFC 3986 §3.2.1 — userinfo grammar requires percent-encoding
                for characters outside unreserved / pct-encoded / sub-delims / ":".
              • RFC 7617 — HTTP Basic Authentication header carries decoded
                user-id and password.

              Environment

              • pymanager: [latest]
              • Windows: [latest]
              • Feed server: [Artifactory]

              Metadata

              Metadata

              Assignees

              Labels

              bugSomething isn't working

              Projects

              No projects

                Milestone

                No milestone

                Relationships

                None yet

                Development

                No branches or pull requests

                Issue actions

                , 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) · Issue #351 · python/pymanager · GitHub
                Skip to content

                extract_url_auth does not percent-decode URL userinfo, breaking HTTP Basic auth for usernames containing '@' (e.g. SAML/SSO email identities) #351

                Description

                @abhisheksaxena29

                Summary

                extract_url_auth in src/manage/urlutils.py returns the userinfo
                fields from winhttp_urlsplit without percent-decoding them, and
                _basic_auth_header then base64-encodes the literal raw string.
                This violates RFC 7617 (HTTP Basic Authentication), which requires
                the header to carry the decoded userinfo, and causes
                authentication to fail against any private runtime feed whose
                username must be percent-encoded in URLs — most commonly an @
                in email-format SAML/SSO usernames.

                Steps to reproduce

                1. Stand up a private runtime feed (e.g. an internal Artifactory /
                  Nexus / S3 generic mirror) protected by HTTP Basic auth, where
                  the authenticating user's name contains @
                  (e.g. user@example.com — typical for SAML-provisioned users).

                2. Configure pymanager with that feed:

                  https://user%40example.com:password@feed.example.com/index.json

                  (%40 is the RFC 3986 §3.2.1 percent-encoding of @, required
                  because @ is the userinfo/host delimiter and is not in the
                  userinfo character set.)

                3. Run pymanager install ... against this feed.

                Expected

                pymanager sends:

                Authorization: Basic <base64("user@example.com:password")>

                …and authentication succeeds (this is what every other Python
                packaging tool does — see "Prior art" below).

                Actual

                pymanager sends:

                Authorization: Basic <base64("user%40example.com:password")>

                The server receives the literal string user%40example.com as the
                username, which does not match the stored user, and returns 401.
                The unencoded URL form (https://user@example.com:password@host/...)
                also fails because WinHttpCrackUrl returns
                ERROR_WINHTTP_INVALID_URL (12005) on unencoded @ in userinfo —
                which the codebase already catches in sanitise_url. So users
                have no working URL form today when the username contains @.

                Root cause

                In src/manage/urlutils.py:

                defextract_url_auth(url):
                ifnoturl:
                returnurlp=winhttp_urlsplit(url)
                user, passw=p[U_USERNAME], p[U_PASSWORD]
                ifuserorpassw:
                returnuseror"", passwor""returnNone

                On the native Windows path, winhttp_urlsplit wraps
                WinHttpCrackUrl, which by default returns pwszUserName /
                pwszPasswordwithout percent-decoding (no ICU_DECODE
                behavior). The values are then passed verbatim to
                _basic_auth_header:

                def_basic_auth_header(username, password):
                frombase64importb64encodepair=f"{username}:{password}".encode("utf-8")
                token=b64encode(pair)
                return"Basic "+token.decode("ascii")

                — no decoding step at any point.
                (The pure-Python fallback winhttp_urlsplit happens to work
                because urllib.parse.urlsplit().username percent-decodes by
                default — so there's also a silent behavior divergence between
                the native and fallback paths.)
                A subtle confirmation that the maintainers already know native
                winhttp_urlsplit returns percent-encoded values: sanitise_url
                in the same file has explicit logic for %-prefixed passwords:

                pw=p[U_PASSWORD]
                ifpwandnot (pw.startswith("%") andpw.startswith("%")):
                p[U_PASSWORD] =None

                Affected users

                Anyone deploying pymanager with a private runtime feed where the
                authenticating identity has @ in the username — i.e. virtually
                every enterprise that uses SAML/SSO with email-format usernames
                and mirrors python.org's index-windows.json through an internal
                artifact repository. This is a very common pattern under
                PEP 773's "alternative feed"
                model.

                Suggested fix

                One-line change in extract_url_auth to percent-decode user and
                password before returning:

                defextract_url_auth(url):
                ifnoturl:
                returnurlp=winhttp_urlsplit(url)
                user, passw=p[U_USERNAME], p[U_PASSWORD]
                ifuserorpassw:
                importurllib.parsereturn (urllib.parse.unquote(useror""),
                urllib.parse.unquote(passwor""))
                returnNone

                This matches what the rest of the Python ecosystem already does
                client-side (see prior art).
                Happy to send a PR if helpful.

                Prior art (this is a well-established convention)

                • pip: pypa/pip#3236
                  (2015, fixed in pip 10.0.0) and
                  pypa/pip#6775 (2019,
                  closing comment by @pradyunsg): "pip 19.2 now requires
                  credentials to be URL quoted. Thus, characters like @ and %
                  need to be URL quoted (like %40 and %25)."
                • pip docs (current):
                  Authentication — Basic HTTP authentication
                  explicitly documents %XX encoding for special characters in
                  credentials and percent-decodes them client-side.
                • Poetry: python-poetry/poetry#686,
                  fixed in #1402.
                • Stack Overflow (70 upvotes, 64k views):
                  Escaping username characters in basic auth URLs
                  — accepted answer cites RFC 3986 §3.2.1 for %40 as the correct
                  form.
                • RFC 3986 §3.2.1 — userinfo grammar requires percent-encoding
                  for characters outside unreserved / pct-encoded / sub-delims / ":".
                • RFC 7617 — HTTP Basic Authentication header carries decoded
                  user-id and password.

                Environment

                • pymanager: [latest]
                • Windows: [latest]
                • Feed server: [Artifactory]

                Metadata

                Metadata

                Assignees

                Labels

                bugSomething isn't working

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions