Skip to content

Commit f5db5d4

Browse files
committed
Initial implementation of uploading with trusted publishing authentication
1 parent a723876 commit f5db5d4

File tree

3 files changed

+47
-1
lines changed

3 files changed

+47
-1
lines changed

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ register = "twine.commands.register:main"
6262

6363
[project.optional-dependencies]
6464
keyring = ["keyring >= 15.1"]
65+
oidc = ["id"]
6566

6667
[project.scripts]
6768
twine = "twine.__main__:main"

twine/auth.py

+35-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
import getpass
33
import logging
44
from typing import TYPE_CHECKING, Callable, Optional, Type, cast
5+
from urllib.parse import urlparse
6+
7+
import requests
58

69
# keyring has an indirect dependency on PyCA cryptography, which has no
710
# pre-built wheels for ppc64le and s390x, see #1158.
@@ -28,9 +31,12 @@ def __init__(
2831

2932

3033
class Resolver:
31-
def __init__(self, config: utils.RepositoryConfig, input: CredentialInput) -> None:
34+
def __init__(
35+
self, config: utils.RepositoryConfig, input: CredentialInput, oidc: bool = False
36+
) -> None:
3237
self.config = config
3338
self.input = input
39+
self.oidc = oidc
3440

3541
@classmethod
3642
def choose(cls, interactive: bool) -> Type["Resolver"]:
@@ -53,13 +59,41 @@ def username(self) -> Optional[str]:
5359
@property
5460
@functools.lru_cache()
5561
def password(self) -> Optional[str]:
62+
if self.oidc:
63+
# Trusted publishing (OpenID Connect): get one token from the CI
64+
# system, and exchange that for a PyPI token.
65+
from id import detect_credential
66+
67+
repository_domain = urlparse(self.system).netloc
68+
audience = self._oidc_audience(repository_domain)
69+
oidc_token = detect_credential(audience)
70+
71+
token_exchange_url = f'https://{repository_domain}/_/oidc/mint-token'
72+
73+
mint_token_resp = requests.post(
74+
token_exchange_url,
75+
json={'token': oidc_token},
76+
timeout=5, # S113 wants a timeout
77+
)
78+
mint_token_resp.raise_for_status()
79+
return mint_token_resp.json()['token']
80+
5681
return utils.get_userpass_value(
5782
self.input.password,
5883
self.config,
5984
key="password",
6085
prompt_strategy=self.password_from_keyring_or_prompt,
6186
)
6287

88+
@staticmethod
89+
def _oidc_audience(repository_domain):
90+
# Indices are expected to support `https://{domain}/_/oidc/audience`,
91+
# which tells OIDC exchange clients which audience to use.
92+
audience_url = f'https://{repository_domain}/_/oidc/audience'
93+
resp = requests.get(audience_url, timeout=5)
94+
resp.raise_for_status()
95+
return resp.json()['audience']
96+
6397
@property
6498
def system(self) -> Optional[str]:
6599
return self.config["repository"]

twine/settings.py

+11
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def __init__(
5151
identity: Optional[str] = None,
5252
username: Optional[str] = None,
5353
password: Optional[str] = None,
54+
trusted_publish: bool = False,
5455
non_interactive: bool = False,
5556
comment: Optional[str] = None,
5657
config_file: str = utils.DEFAULT_CONFIG_FILE,
@@ -128,6 +129,7 @@ def __init__(
128129
self.auth = auth.Resolver.choose(not non_interactive)(
129130
self.repository_config,
130131
auth.CredentialInput(username, password),
132+
oidc=trusted_publish,
131133
)
132134

133135
@property
@@ -222,6 +224,15 @@ def register_argparse_arguments(parser: argparse.ArgumentParser) -> None:
222224
"(package index) with. (Can also be set via "
223225
"%(env)s environment variable.)",
224226
)
227+
parser.add_argument(
228+
"--trusted-publish",
229+
default=False,
230+
required=False,
231+
action="store_true",
232+
help="Upload from CI using trusted publishing. Use this without "
233+
"specifying username & password. Requires an optional extra "
234+
"dependency (install twine[oidc]).",
235+
)
225236
parser.add_argument(
226237
"--non-interactive",
227238
action=utils.EnvironmentFlag,

0 commit comments

Comments
 (0)