Skip to content

Commit 48cca6d

Browse files
committed
Implement RFC7617-compliant multi-domain basic authentication
The https://datatracker.ietf.org/doc/html/rfc7617#section-2.2 defines multi-domain authentication behaviour and authentication scopes for basic authentication. This change improves the implementation of the multi-domain matching to be RC7617 compliant * path matching (including longest match) * scheme validation matching Closes: pypa#10902
1 parent ec8edbf commit 48cca6d

File tree

2 files changed

+120
-30
lines changed

2 files changed

+120
-30
lines changed

src/pip/_internal/network/auth.py

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def __init__(
7676
) -> None:
7777
self.prompting = prompting
7878
self.index_urls = index_urls
79-
self.passwords: Dict[str, AuthInfo] = {}
79+
self.passwords: List[Tuple[str, AuthInfo]] = []
8080
# When the user is prompted to enter credentials and keyring is
8181
# available, we will offer to save them. If the user accepts,
8282
# this value is set to the credentials they entered. After the
@@ -162,6 +162,46 @@ def _get_new_credentials(
162162

163163
return username, password
164164

165+
def _get_matching_credentials(self, original_url) -> Optional[AuthInfo]:
166+
"""
167+
Find matching credentials based on the longest matching prefix found.
168+
169+
According to https://datatracker.ietf.org/doc/html/rfc7617#section-2.2
170+
the authentication scope is defined by removing the last part after
171+
the last "/" of the resource - but in case of `pip` the end of path is always
172+
"/project", so we can treat the `original_url` as full `authentication scope'
173+
for the request. The RFC specifies that prefix matching of the scope is
174+
also within the protection scope specified by the realm value, so we can
175+
safely assume that if we find any match, the longest matching prefix is
176+
the right authentication information we should use.
177+
178+
The specification does not decide which of the matching scopes should be
179+
used if there are more of them. Our decision is to choose the longest
180+
matching one. In case exactly the same prefix will be added several times,
181+
the authentication information from the first one is used.
182+
183+
According to the RFC, the authentication scope includes both scheme and netloc,
184+
Both have to match in order to share the authentication scope.
185+
186+
:param original_url: original URL of the request
187+
:return: Stored Authentication info matching the authentication scope or None if not found
188+
"""
189+
max_matched_prefix_length = 0
190+
matched_auth_info = None
191+
no_auth_url = remove_auth_from_url(original_url)
192+
parsed_original = urllib.parse.urlparse(no_auth_url)
193+
for prefix, auth_info in self.passwords:
194+
parsed_stored = urllib.parse.urlparse(prefix)
195+
if parsed_stored.netloc != parsed_original.netloc \
196+
or parsed_stored.scheme != parsed_original.scheme:
197+
# only consider match when both scheme and netloc match
198+
continue
199+
current_matching_prefix_length = sum(a == b for a, b in zip(prefix, no_auth_url))
200+
if current_matching_prefix_length > max_matched_prefix_length:
201+
matched_auth_info = auth_info
202+
max_matched_prefix_length = current_matching_prefix_length
203+
return matched_auth_info
204+
165205
def _get_url_and_credentials(
166206
self, original_url: str
167207
) -> Tuple[str, Optional[str], Optional[str]]:
@@ -183,12 +223,14 @@ def _get_url_and_credentials(
183223
# Do this if either the username or the password is missing.
184224
# This accounts for the situation in which the user has specified
185225
# the username in the index url, but the password comes from keyring.
186-
if (username is None or password is None) and netloc in self.passwords:
187-
un, pw = self.passwords[netloc]
188-
# It is possible that the cached credentials are for a different username,
189-
# in which case the cache should be ignored.
190-
if username is None or username == un:
191-
username, password = un, pw
226+
if username is None or password is None:
227+
matched_credentials = self._get_matching_credentials(original_url)
228+
if matched_credentials:
229+
un, pw = matched_credentials
230+
# It is possible that the cached credentials are for a different username,
231+
# in which case the cache should be ignored.
232+
if username is None or username == un:
233+
username, password = un, pw
192234

193235
if username is not None or password is not None:
194236
# Convert the username and password if they're None, so that
@@ -199,7 +241,7 @@ def _get_url_and_credentials(
199241
password = password or ""
200242

201243
# Store any acquired credentials.
202-
self.passwords[netloc] = (username, password)
244+
self.passwords.append((remove_auth_from_url(url), (username, password)))
203245

204246
assert (
205247
# Credentials were found
@@ -272,7 +314,7 @@ def handle_401(self, resp: Response, **kwargs: Any) -> Response:
272314
# Store the new username and password to use for future requests
273315
self._credentials_to_save = None
274316
if username is not None and password is not None:
275-
self.passwords[parsed.netloc] = (username, password)
317+
self.passwords.append((resp.url, (username, password)))
276318

277319
# Prompt to save the password to keyring
278320
if save and self._should_save_password_to_keyring():

tests/unit/test_network_auth.py

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,26 @@
1212
["input_url", "url", "username", "password"],
1313
[
1414
(
15-
"http://user%40email.com:[email protected]/path",
16-
"http://example.com/path",
15+
"https://user%40email.com:[email protected]/path",
16+
"https://example.com/path",
1717
1818
"password",
1919
),
2020
(
21-
"http://username:[email protected]/path",
22-
"http://example.com/path",
21+
"https://username:[email protected]/path",
22+
"https://example.com/path",
2323
"username",
2424
"password",
2525
),
2626
(
27-
"http://[email protected]/path",
28-
"http://example.com/path",
27+
"https://[email protected]/path",
28+
"https://example.com/path",
2929
"token",
3030
"",
3131
),
3232
(
33-
"http://example.com/path",
34-
"http://example.com/path",
33+
"https://example.com/path",
34+
"https://example.com/path",
3535
None,
3636
None,
3737
),
@@ -50,43 +50,91 @@ def test_get_credentials_parses_correctly(
5050
(username is None and password is None)
5151
or
5252
# Credentials were found and "cached" appropriately
53-
auth.passwords["example.com"] == (username, password)
53+
(url, (username, password)) in auth.passwords
5454
)
5555

5656

5757
def test_get_credentials_not_to_uses_cached_credentials() -> None:
5858
auth = MultiDomainBasicAuth()
59-
auth.passwords["example.com"] = ("user", "pass")
59+
auth.passwords.append(("https://example.com", ("user", "pass")))
6060

61-
got = auth._get_url_and_credentials("http://foo:[email protected]/path")
62-
expected = ("http://example.com/path", "foo", "bar")
61+
got = auth._get_url_and_credentials("https://foo:[email protected]/path")
62+
expected = ("https://example.com/path", "foo", "bar")
6363
assert got == expected
6464

6565

66-
def test_get_credentials_not_to_uses_cached_credentials_only_username() -> None:
66+
def test_get_credentials_not_to_use_cached_credentials_only_username() -> None:
6767
auth = MultiDomainBasicAuth()
68-
auth.passwords["example.com"] = ("user", "pass")
68+
auth.passwords.append(("https://example.com", ("user", "pass")))
6969

70-
got = auth._get_url_and_credentials("http://[email protected]/path")
71-
expected = ("http://example.com/path", "foo", "")
70+
got = auth._get_url_and_credentials("https://[email protected]/path")
71+
expected = ("https://example.com/path", "foo", "")
72+
assert got == expected
73+
74+
75+
def test_multi_domain_credentials_match() -> None:
76+
auth = MultiDomainBasicAuth()
77+
auth.passwords.append(("https://example.com", ("user", "pass")))
78+
auth.passwords.append(("https://example.com/path", ("user", "pass2")))
79+
80+
got = auth._get_url_and_credentials("https://[email protected]/path")
81+
expected = ("https://example.com/path", "user", "pass2")
82+
assert got == expected
83+
84+
85+
def test_multi_domain_credentials_longest_match() -> None:
86+
auth = MultiDomainBasicAuth()
87+
auth.passwords.append(("https://example.com", ("user", "pass")))
88+
auth.passwords.append(("https://example.com/path", ("user", "pass2")))
89+
auth.passwords.append(("https://example.com/path/subpath", ("user", "pass3")))
90+
91+
got = auth._get_url_and_credentials("https://[email protected]/path")
92+
expected = ("https://example.com/path", "user", "pass2")
7293
assert got == expected
7394

7495

7596
def test_get_credentials_uses_cached_credentials() -> None:
7697
auth = MultiDomainBasicAuth()
77-
auth.passwords["example.com"] = ("user", "pass")
98+
auth.passwords.append(("https://example.com", ("user", "pass")))
99+
100+
got = auth._get_url_and_credentials("https://example.com/path")
101+
expected = ("https://example.com/path", "user", "pass")
102+
assert got == expected
103+
104+
105+
def test_get_credentials_not_uses_cached_credentials_different_scheme_http() -> None:
106+
auth = MultiDomainBasicAuth()
107+
auth.passwords.append(("http://example.com", ("user", "pass")))
108+
109+
got = auth._get_url_and_credentials("https://example.com/path")
110+
expected = ("https://example.com/path", None, None)
111+
assert got == expected
112+
113+
114+
def test_get_credentials_not_uses_cached_credentials_different_scheme_https() -> None:
115+
auth = MultiDomainBasicAuth()
116+
auth.passwords.append(("https://example.com", ("user", "pass")))
78117

79118
got = auth._get_url_and_credentials("http://example.com/path")
80-
expected = ("http://example.com/path", "user", "pass")
119+
expected = ("http://example.com/path", None, None)
81120
assert got == expected
82121

83122

84123
def test_get_credentials_uses_cached_credentials_only_username() -> None:
85124
auth = MultiDomainBasicAuth()
86-
auth.passwords["example.com"] = ("user", "pass")
125+
auth.passwords.append(("https://example.com", ("user", "pass")))
126+
127+
got = auth._get_url_and_credentials("https://[email protected]/path")
128+
expected = ("https://example.com/path", "user", "pass")
129+
assert got == expected
130+
131+
132+
def test_get_credentials_uses_cached_credentials_wrong_username() -> None:
133+
auth = MultiDomainBasicAuth()
134+
auth.passwords.append(("https://example.com", ("user", "pass")))
87135

88-
got = auth._get_url_and_credentials("http://user@example.com/path")
89-
expected = ("http://example.com/path", "user", "pass")
136+
got = auth._get_url_and_credentials("https://user2@example.com/path")
137+
expected = ("https://example.com/path", "user2", "")
90138
assert got == expected
91139

92140

0 commit comments

Comments
 (0)