Skip to content

Commit 288b435

Browse files
abnradoering
andauthored
feat(auth): allow passwords with empty usernames (#10088)
Co-authored-by: Randy Döring <[email protected]>
1 parent b4b7729 commit 288b435

File tree

5 files changed

+37
-20
lines changed

5 files changed

+37
-20
lines changed

docs/repositories.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,10 @@ required to set an empty password. This is supported by Poetry.
531531
poetry config http-basic.foo <TOKEN> ""
532532
```
533533

534-
**Note:** Usernames cannot be empty. Attempting to use an empty username can result in an unpredictable failure.
534+
**Note:** Empty usernames are discouraged. However, Poetry will honour them if a password is configured without it. This
535+
is unfortunately commonplace practice, while not best practice, for private indices that use tokens. When a password is
536+
stored into the system keyring with an empty username, Poetry will use a literal `__poetry_source_empty_username__` as
537+
the username to circumvent [keyring#687](https://github.com/jaraco/keyring/pull/687).
535538
{{% /note %}}
536539

537540
## Certificates

src/poetry/utils/password_manager.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ class HTTPAuthCredential:
2727

2828

2929
class PoetryKeyring:
30+
# some private sources expect tokens to be provided as passwords with empty userames
31+
# we use a fixed literal to ensure that this can be stored in keyring (jaraco/keyring#687)
32+
#
33+
# Note: If this is changed, users with passwords stored with empty usernames will have to
34+
# re-add the config.
35+
_EMPTY_USERNAME_KEY = "__poetry_source_empty_username__"
36+
3037
def __init__(self, namespace: str) -> None:
3138
self._namespace = namespace
3239

@@ -41,6 +48,7 @@ def get_credential(
4148
for name in names:
4249
credential = None
4350
try:
51+
# we do default to empty username string here since credentials support empty usernames
4452
credential = keyring.get_credential(name, username)
4553
except KeyringLocked:
4654
logger.debug("Keyring %s is locked", name)
@@ -61,7 +69,7 @@ def get_password(self, name: str, username: str) -> str | None:
6169
name = self.get_entry_name(name)
6270

6371
try:
64-
return keyring.get_password(name, username)
72+
return keyring.get_password(name, username or self._EMPTY_USERNAME_KEY)
6573
except (RuntimeError, keyring.errors.KeyringError):
6674
raise PoetryKeyringError(
6775
f"Unable to retrieve the password for {name} from the key ring"
@@ -74,7 +82,7 @@ def set_password(self, name: str, username: str, password: str) -> None:
7482
name = self.get_entry_name(name)
7583

7684
try:
77-
keyring.set_password(name, username, password)
85+
keyring.set_password(name, username or self._EMPTY_USERNAME_KEY, password)
7886
except (RuntimeError, keyring.errors.KeyringError) as e:
7987
raise PoetryKeyringError(
8088
f"Unable to store the password for {name} in the key ring: {e}"
@@ -86,7 +94,7 @@ def delete_password(self, name: str, username: str) -> None:
8694
name = self.get_entry_name(name)
8795

8896
try:
89-
keyring.delete_password(name, username)
97+
keyring.delete_password(name, username or self._EMPTY_USERNAME_KEY)
9098
except (RuntimeError, keyring.errors.KeyringError):
9199
raise PoetryKeyringError(
92100
f"Unable to delete the password for {name} from the key ring"
@@ -196,13 +204,11 @@ def get_http_auth(self, repo_name: str) -> HTTPAuthCredential:
196204
username = self._config.get(f"http-basic.{repo_name}.username")
197205
password = self._config.get(f"http-basic.{repo_name}.password")
198206

199-
if not username:
200-
return HTTPAuthCredential()
201-
202207
if password is None and self.use_keyring:
203208
password = self.keyring.get_password(repo_name, username)
204209

205-
return HTTPAuthCredential(username=username, password=password)
210+
# we use `or None` here to ensure that empty strings are passed as None
211+
return HTTPAuthCredential(username=username or None, password=password or None)
206212

207213
def set_http_password(self, repo_name: str, username: str, password: str) -> None:
208214
auth = {"username": username}

tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from poetry.utils.env import EnvManager
3636
from poetry.utils.env import SystemEnv
3737
from poetry.utils.env import VirtualEnv
38+
from poetry.utils.password_manager import PoetryKeyring
3839
from tests.helpers import MOCK_DEFAULT_GIT_REVISION
3940
from tests.helpers import TestLocker
4041
from tests.helpers import TestRepository
@@ -191,6 +192,11 @@ def get_credential(
191192
raise KeyringError()
192193

193194

195+
@pytest.fixture()
196+
def poetry_keyring() -> PoetryKeyring:
197+
return PoetryKeyring("poetry-repository")
198+
199+
194200
@pytest.fixture()
195201
def dummy_keyring() -> DummyBackend:
196202
return DummyBackend()

tests/utils/test_authenticator.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from pytest import MonkeyPatch
2626
from pytest_mock import MockerFixture
2727

28+
from poetry.utils.password_manager import PoetryKeyring
2829
from tests.conftest import Config
2930
from tests.conftest import DummyBackend
3031

@@ -153,7 +154,7 @@ def test_authenticator_uses_empty_strings_as_default_password(
153154
assert request.headers["Authorization"] == f"Basic {basic_auth}"
154155

155156

156-
def test_authenticator_ignores_empty_strings_as_default_username(
157+
def test_authenticator_does_not_ignore_empty_strings_as_default_username(
157158
config: Config,
158159
mock_remote: None,
159160
repo: dict[str, dict[str, str]],
@@ -170,7 +171,8 @@ def test_authenticator_ignores_empty_strings_as_default_username(
170171
authenticator.request("get", "https://foo.bar/files/foo-0.1.0.tar.gz")
171172

172173
request = http.last_request()
173-
assert request.headers["Authorization"] is None
174+
basic_auth = base64.b64encode(b":bar").decode()
175+
assert request.headers["Authorization"] == f"Basic {basic_auth}"
174176

175177

176178
def test_authenticator_falls_back_to_keyring_url(
@@ -207,6 +209,7 @@ def test_authenticator_falls_back_to_keyring_netloc(
207209
http: type[httpretty.httpretty],
208210
with_simple_keyring: None,
209211
dummy_keyring: DummyBackend,
212+
poetry_keyring: PoetryKeyring,
210213
) -> None:
211214
config.merge(
212215
{

tests/utils/test_password_manager.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import contextlib
43
import logging
54
import os
65

@@ -42,7 +41,7 @@ def test_set_http_password(
4241
("username", "password", "is_valid"),
4342
[
4443
("bar", "baz", True),
45-
("", "baz", False),
44+
("", "baz", True),
4645
("bar", "", True),
4746
("", "", False),
4847
],
@@ -53,10 +52,10 @@ def test_get_http_auth(
5352
is_valid: bool,
5453
config: Config,
5554
with_simple_keyring: None,
56-
dummy_keyring: DummyBackend,
55+
poetry_keyring: PoetryKeyring,
5756
) -> None:
58-
with contextlib.nullcontext() if username else pytest.warns(DeprecationWarning):
59-
dummy_keyring.set_password("poetry-repository-foo", username, password)
57+
poetry_keyring.set_password("foo", username, password)
58+
6059
config.auth_config_source.add_property("http-basic.foo", {"username": username})
6160
manager = PasswordManager(config)
6261

@@ -65,8 +64,8 @@ def test_get_http_auth(
6564

6665
if is_valid:
6766
assert auth is not None
68-
assert auth.username == username
69-
assert auth.password == password
67+
assert auth.username == (username or None)
68+
assert auth.password == (password or None)
7069
else:
7170
assert auth.username is auth.password is None
7271

@@ -137,7 +136,7 @@ def test_set_http_password_with_unavailable_backend(
137136
("username", "password", "is_valid"),
138137
[
139138
("bar", "baz", True),
140-
("", "baz", False),
139+
("", "baz", True),
141140
("bar", "", True),
142141
("", "", False),
143142
],
@@ -159,8 +158,8 @@ def test_get_http_auth_with_unavailable_backend(
159158

160159
if is_valid:
161160
assert auth is not None
162-
assert auth.username == username
163-
assert auth.password == password
161+
assert auth.username == (username or None)
162+
assert auth.password == (password or None)
164163
else:
165164
assert auth.username is auth.password is None
166165

0 commit comments

Comments
 (0)