Skip to content

Commit

Permalink
Try trusted publishing on upload to PyPI with no token
Browse files Browse the repository at this point in the history
  • Loading branch information
takluyver committed Dec 4, 2024
1 parent 6b482f9 commit f6acaf3
Show file tree
Hide file tree
Showing 2 changed files with 36 additions and 34 deletions.
59 changes: 36 additions & 23 deletions twine/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
except ModuleNotFoundError: # pragma: no cover
keyring = None

try:
from id import detect_credential # type: ignore
except ModuleNotFoundError: # pragma: no cover
detect_credential = None

from twine import exceptions
from twine import utils

Expand All @@ -35,11 +40,9 @@ def __init__(
self,
config: utils.RepositoryConfig,
input: CredentialInput,
trusted_publishing: bool = False,
) -> None:
self.config = config
self.input = input
self.trusted_publishing = trusted_publishing

@classmethod
def choose(cls, interactive: bool) -> Type["Resolver"]:
Expand All @@ -62,24 +65,16 @@ def username(self) -> Optional[str]:
@property
@functools.lru_cache()
def password(self) -> Optional[str]:
if self.trusted_publishing:
# Trusted publishing (OpenID Connect): get one token from the CI
# system, and exchange that for a PyPI token.
from id import detect_credential # type: ignore

repository_domain = cast(str, urlparse(self.system).netloc)
audience = self._oidc_audience(repository_domain)
oidc_token = detect_credential(audience)

token_exchange_url = f"https://{repository_domain}/_/oidc/mint-token"

mint_token_resp = requests.post(
token_exchange_url,
json={"token": oidc_token},
timeout=5, # S113 wants a timeout
if (
self.is_pypi()
and self.username == "__token__"
and self.input.password is None
and detect_credential is not None
):
logger.info(
"Trying to use trusted publishing (no token was explicitly provided)"
)
mint_token_resp.raise_for_status()
return cast(str, mint_token_resp.json()["token"])
return self.make_trusted_publishing_token()

return utils.get_userpass_value(
self.input.password,
Expand All @@ -88,14 +83,32 @@ def password(self) -> Optional[str]:
prompt_strategy=self.password_from_keyring_or_prompt,
)

@staticmethod
def _oidc_audience(repository_domain: str) -> str:
def make_trusted_publishing_token(self) -> str:
# Trusted publishing (OpenID Connect): get one token from the CI
# system, and exchange that for a PyPI token.
repository_domain = cast(str, urlparse(self.system).netloc)
session = requests.Session() # TODO: user agent & retries

# Indices are expected to support `https://{domain}/_/oidc/audience`,
# which tells OIDC exchange clients which audience to use.
audience_url = f"https://{repository_domain}/_/oidc/audience"
resp = requests.get(audience_url, timeout=5)
resp = session.get(audience_url, timeout=5)
resp.raise_for_status()
return cast(str, resp.json()["audience"])
audience = cast(str, resp.json()["audience"])

oidc_token = detect_credential(audience)
logger.debug("Got OIDC token for audience %s", audience)

token_exchange_url = f"https://{repository_domain}/_/oidc/mint-token"

mint_token_resp = session.post(
token_exchange_url,
json={"token": oidc_token},
timeout=5, # S113 wants a timeout
)
mint_token_resp.raise_for_status()
logger.debug("Minted upload token for trusted publishing")
return cast(str, mint_token_resp.json()["token"])

@property
def system(self) -> Optional[str]:
Expand Down
11 changes: 0 additions & 11 deletions twine/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ def __init__(
identity: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
trusted_publish: bool = False,
non_interactive: bool = False,
comment: Optional[str] = None,
config_file: str = utils.DEFAULT_CONFIG_FILE,
Expand Down Expand Up @@ -129,7 +128,6 @@ def __init__(
self.auth = auth.Resolver.choose(not non_interactive)(
self.repository_config,
auth.CredentialInput(username, password),
trusted_publishing=trusted_publish,
)

@property
Expand Down Expand Up @@ -224,15 +222,6 @@ def register_argparse_arguments(parser: argparse.ArgumentParser) -> None:
"(package index) with. (Can also be set via "
"%(env)s environment variable.)",
)
parser.add_argument(
"--trusted-publish",
default=False,
required=False,
action="store_true",
help="Upload from CI using trusted publishing. Use this without "
"specifying username & password. Requires an optional extra "
"dependency (install twine[trusted-publishing]).",
)
parser.add_argument(
"--non-interactive",
action=utils.EnvironmentFlag,
Expand Down

0 comments on commit f6acaf3

Please sign in to comment.