Skip to content

Commit 28e60bb

Browse files
authored
Merge pull request #1194 from takluyver/oidc
Initial implementation of uploading with trusted publishing authentication
2 parents 66b25ae + ec859fb commit 28e60bb

File tree

6 files changed

+117
-34
lines changed

6 files changed

+117
-34
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ dependencies = [
4242
"rfc3986 >= 1.4.0",
4343
"rich >= 12.0.0",
4444
"packaging",
45+
"id",
4546
]
4647
dynamic = ["version"]
4748

twine/auth.py

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
11
import functools
22
import getpass
3+
import json
34
import logging
45
from typing import TYPE_CHECKING, Callable, Optional, Type, cast
6+
from urllib.parse import urlparse
7+
8+
from id import AmbientCredentialError # type: ignore
9+
from id import detect_credential
510

611
# keyring has an indirect dependency on PyCA cryptography, which has no
712
# pre-built wheels for ppc64le and s390x, see #1158.
813
if TYPE_CHECKING:
914
import keyring
15+
from keyring.errors import NoKeyringError
1016
else:
1117
try:
1218
import keyring
19+
from keyring.errors import NoKeyringError
1320
except ModuleNotFoundError: # pragma: no cover
1421
keyring = None
22+
NoKeyringError = None
1523

1624
from twine import exceptions
1725
from twine import utils
@@ -28,7 +36,11 @@ def __init__(
2836

2937

3038
class Resolver:
31-
def __init__(self, config: utils.RepositoryConfig, input: CredentialInput) -> None:
39+
def __init__(
40+
self,
41+
config: utils.RepositoryConfig,
42+
input: CredentialInput,
43+
) -> None:
3244
self.config = config
3345
self.input = input
3446

@@ -57,9 +69,65 @@ def password(self) -> Optional[str]:
5769
self.input.password,
5870
self.config,
5971
key="password",
60-
prompt_strategy=self.password_from_keyring_or_prompt,
72+
prompt_strategy=self.password_from_keyring_or_trusted_publishing_or_prompt,
6173
)
6274

75+
def make_trusted_publishing_token(self) -> Optional[str]:
76+
# Trusted publishing (OpenID Connect): get one token from the CI
77+
# system, and exchange that for a PyPI token.
78+
repository_domain = cast(str, urlparse(self.system).netloc)
79+
session = utils.make_requests_session()
80+
81+
# Indices are expected to support `https://{domain}/_/oidc/audience`,
82+
# which tells OIDC exchange clients which audience to use.
83+
audience_url = f"https://{repository_domain}/_/oidc/audience"
84+
resp = session.get(audience_url, timeout=5)
85+
resp.raise_for_status()
86+
audience = cast(str, resp.json()["audience"])
87+
88+
try:
89+
oidc_token = detect_credential(audience)
90+
except AmbientCredentialError as e:
91+
# If we get here, we're on a supported CI platform for trusted
92+
# publishing, and we have not been given any token, so we can error.
93+
raise exceptions.TrustedPublishingFailure(
94+
"Unable to retrieve an OIDC token from the CI platform for "
95+
f"trusted publishing {e}"
96+
)
97+
98+
if oidc_token is None:
99+
logger.debug("This environment is not supported for trusted publishing")
100+
return None # Fall back to prompting for a token (if possible)
101+
102+
logger.debug("Got OIDC token for audience %s", audience)
103+
104+
token_exchange_url = f"https://{repository_domain}/_/oidc/mint-token"
105+
106+
mint_token_resp = session.post(
107+
token_exchange_url,
108+
json={"token": oidc_token},
109+
timeout=5, # S113 wants a timeout
110+
)
111+
try:
112+
mint_token_payload = mint_token_resp.json()
113+
except json.JSONDecodeError:
114+
raise exceptions.TrustedPublishingFailure(
115+
"The token-minting request returned invalid JSON"
116+
)
117+
118+
if not mint_token_resp.ok:
119+
reasons = "\n".join(
120+
f'* `{error["code"]}`: {error["description"]}'
121+
for error in mint_token_payload["errors"]
122+
)
123+
raise exceptions.TrustedPublishingFailure(
124+
"The token request failed; the index server gave the following "
125+
f"reasons:\n\n{reasons}"
126+
)
127+
128+
logger.debug("Minted upload token for trusted publishing")
129+
return cast(str, mint_token_payload["token"])
130+
63131
@property
64132
def system(self) -> Optional[str]:
65133
return self.config["repository"]
@@ -90,6 +158,8 @@ def get_password_from_keyring(self) -> Optional[str]:
90158
username = cast(str, self.username)
91159
logger.info("Querying keyring for password")
92160
return cast(str, keyring.get_password(system, username))
161+
except NoKeyringError:
162+
logger.info("No keyring backend found")
93163
except Exception as exc:
94164
logger.warning("Error getting password from keyring", exc_info=exc)
95165
return None
@@ -102,12 +172,19 @@ def username_from_keyring_or_prompt(self) -> str:
102172

103173
return self.prompt("username", input)
104174

105-
def password_from_keyring_or_prompt(self) -> str:
175+
def password_from_keyring_or_trusted_publishing_or_prompt(self) -> str:
106176
password = self.get_password_from_keyring()
107177
if password:
108178
logger.info("password set from keyring")
109179
return password
110180

181+
if self.is_pypi() and self.username == "__token__":
182+
logger.debug(
183+
"Trying to use trusted publishing (no token was explicitly provided)"
184+
)
185+
if (token := self.make_trusted_publishing_token()) is not None:
186+
return token
187+
111188
# Prompt for API token when required.
112189
what = "API token" if self.is_pypi() else "password"
113190

twine/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def list_dependencies_and_versions() -> List[Tuple[str, str]]:
8181
"requests",
8282
"requests-toolbelt",
8383
"urllib3",
84+
"id",
8485
]
8586
if sys.version_info < (3, 10):
8687
deps.append("importlib-metadata")

twine/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ class NonInteractive(TwineException):
116116
pass
117117

118118

119+
class TrustedPublishingFailure(TwineException):
120+
"""Raised if we expected to use trusted publishing but couldn't."""
121+
122+
pass
123+
124+
119125
class InvalidPyPIUploadURL(TwineException):
120126
"""Repository configuration tries to use PyPI with an incorrect URL.
121127

twine/repository.py

Lines changed: 3 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,15 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
import logging
15-
from typing import Any, Dict, List, Optional, Set, Tuple, cast
15+
from typing import Any, Dict, List, Optional, Set, Tuple
1616

1717
import requests
1818
import requests_toolbelt
1919
import rich.progress
20-
import urllib3
21-
from requests import adapters
22-
from requests_toolbelt.utils import user_agent
2320
from rich import print
2421

25-
import twine
2622
from twine import package as package_file
23+
from twine.utils import make_requests_session
2724

2825
KEYWORDS_TO_NOT_FLATTEN = {"gpg_signature", "attestations", "content"}
2926

@@ -47,7 +44,7 @@ def __init__(
4744
) -> None:
4845
self.url = repository_url
4946

50-
self.session = requests.session()
47+
self.session = make_requests_session()
5148
# requests.Session.auth should be Union[None, Tuple[str, str], ...]
5249
# But username or password could be None
5350
# See TODO for utils.RepositoryConfig
@@ -57,35 +54,10 @@ def __init__(
5754
logger.info(f"username: {username if username else '<empty>'}")
5855
logger.info(f"password: <{'hidden' if password else 'empty'}>")
5956

60-
self.session.headers["User-Agent"] = self._make_user_agent_string()
61-
for scheme in ("http://", "https://"):
62-
self.session.mount(scheme, self._make_adapter_with_retries())
63-
6457
# Working around https://github.com/python/typing/issues/182
6558
self._releases_json_data: Dict[str, Dict[str, Any]] = {}
6659
self.disable_progress_bar = disable_progress_bar
6760

68-
@staticmethod
69-
def _make_adapter_with_retries() -> adapters.HTTPAdapter:
70-
retry = urllib3.Retry(
71-
allowed_methods=["GET"],
72-
connect=5,
73-
total=10,
74-
status_forcelist=[500, 501, 502, 503],
75-
)
76-
77-
return adapters.HTTPAdapter(max_retries=retry)
78-
79-
@staticmethod
80-
def _make_user_agent_string() -> str:
81-
user_agent_string = (
82-
user_agent.UserAgentBuilder("twine", twine.__version__)
83-
.include_implementation()
84-
.build()
85-
)
86-
87-
return cast(str, user_agent_string)
88-
8961
def close(self) -> None:
9062
self.session.close()
9163

twine/utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@
2525

2626
import requests
2727
import rfc3986
28+
import urllib3
29+
from requests.adapters import HTTPAdapter
30+
from requests_toolbelt.utils import user_agent
2831

32+
import twine
2933
from twine import exceptions
3034

3135
# Shim for input to allow testing.
@@ -304,6 +308,28 @@ def get_userpass_value(
304308
get_clientcert = functools.partial(get_userpass_value, key="client_cert")
305309

306310

311+
def make_requests_session() -> requests.Session:
312+
"""Prepare a requests Session with retries & twine's user-agent string."""
313+
s = requests.Session()
314+
315+
retry = urllib3.Retry(
316+
allowed_methods=["GET"],
317+
connect=5,
318+
total=10,
319+
status_forcelist=[500, 501, 502, 503],
320+
)
321+
322+
for scheme in ("http://", "https://"):
323+
s.mount(scheme, HTTPAdapter(max_retries=retry))
324+
325+
s.headers["User-Agent"] = (
326+
user_agent.UserAgentBuilder("twine", twine.__version__)
327+
.include_implementation()
328+
.build()
329+
)
330+
return s
331+
332+
307333
class EnvironmentDefault(argparse.Action):
308334
"""Get values from environment variable."""
309335

0 commit comments

Comments
 (0)