From b5ebf12e7c5cc0f9bab0266b18d8b7165bbf99ed Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Wed, 4 Dec 2024 10:51:00 +0000 Subject: [PATCH] Initial implementation of uploading with trusted publishing authentication --- pyproject.toml | 1 + twine/auth.py | 36 +++++++++++++++++++++++++++++++++++- twine/settings.py | 11 +++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 24e50529..1213412f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ register = "twine.commands.register:main" [project.optional-dependencies] keyring = ["keyring >= 15.1"] +oidc = ["id"] [project.scripts] twine = "twine.__main__:main" diff --git a/twine/auth.py b/twine/auth.py index 59e2e21d..916b050f 100644 --- a/twine/auth.py +++ b/twine/auth.py @@ -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. @@ -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"]: @@ -53,6 +59,25 @@ 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, @@ -60,6 +85,15 @@ def password(self) -> Optional[str]: 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"] diff --git a/twine/settings.py b/twine/settings.py index 9f3aefb1..32ac9816 100644 --- a/twine/settings.py +++ b/twine/settings.py @@ -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, @@ -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 @@ -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,