Skip to content

Commit

Permalink
Initial implementation of uploading with trusted publishing authentic…
Browse files Browse the repository at this point in the history
…ation
  • Loading branch information
takluyver committed Dec 4, 2024
1 parent 22e2e61 commit b5ebf12
Show file tree
Hide file tree
Showing 3 changed files with 47 additions and 1 deletion.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ register = "twine.commands.register:main"

[project.optional-dependencies]
keyring = ["keyring >= 15.1"]
oidc = ["id"]

[project.scripts]
twine = "twine.__main__:main"
Expand Down
36 changes: 35 additions & 1 deletion twine/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
import getpass
import logging
from typing import TYPE_CHECKING, Callable, Optional, Type, cast
from urllib.parse import urlparse

import requests

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


class Resolver:
def __init__(self, config: utils.RepositoryConfig, input: CredentialInput) -> None:
def __init__(
self, config: utils.RepositoryConfig, input: CredentialInput, oidc: bool = False
) -> None:
self.config = config
self.input = input
self.oidc = oidc

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

repository_domain = 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
)
mint_token_resp.raise_for_status()
return mint_token_resp.json()['token']

return utils.get_userpass_value(
self.input.password,
self.config,
key="password",
prompt_strategy=self.password_from_keyring_or_prompt,
)

@staticmethod
def _oidc_audience(repository_domain):
# 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.raise_for_status()
return resp.json()['audience']

@property
def system(self) -> Optional[str]:
return self.config["repository"]
Expand Down
11 changes: 11 additions & 0 deletions twine/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ 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 @@ -128,6 +129,7 @@ def __init__(
self.auth = auth.Resolver.choose(not non_interactive)(
self.repository_config,
auth.CredentialInput(username, password),
oidc=trusted_publish,
)

@property
Expand Down Expand Up @@ -222,6 +224,15 @@ 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[oidc]).",
)
parser.add_argument(
"--non-interactive",
action=utils.EnvironmentFlag,
Expand Down

0 comments on commit b5ebf12

Please sign in to comment.