Skip to content

Initial implementation of uploading with trusted publishing authentication #1194

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Dec 11, 2024
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ dependencies = [
"rfc3986 >= 1.4.0",
"rich >= 12.0.0",
"packaging",
"id",
]
dynamic = ["version"]

Expand Down
79 changes: 76 additions & 3 deletions twine/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import functools
import getpass
import json
import logging
from typing import TYPE_CHECKING, Callable, Optional, Type, cast
from urllib.parse import urlparse

import requests
from id import AmbientCredentialError # type: ignore
from id import detect_credential

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


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

Expand Down Expand Up @@ -57,9 +67,65 @@ def password(self) -> Optional[str]:
self.input.password,
self.config,
key="password",
prompt_strategy=self.password_from_keyring_or_prompt,
prompt_strategy=self.password_from_keyring_or_trusted_publishing_or_prompt,
)

def make_trusted_publishing_token(self) -> Optional[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 = session.get(audience_url, timeout=5)
resp.raise_for_status()
audience = cast(str, resp.json()["audience"])

try:
oidc_token = detect_credential(audience)
except AmbientCredentialError as e:
# If we get here, we're on a supported CI platform for trusted
# publishing, and we have not been given any token, so we can error.
raise exceptions.TrustedPublishingFailure(
"Unable to retrieve an OIDC token from the CI platform for "
f"trusted publishing {e}"
)

if oidc_token is None:
logger.debug("This environment is not supported for trusted publishing")
return None # Fall back to prompting for a token (if possible)

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
)
try:
mint_token_payload = mint_token_resp.json()
except json.JSONDecodeError:
raise exceptions.TrustedPublishingFailure(
"The token-minting request returned invalid JSON"
)

if not mint_token_resp.ok:
reasons = "\n".join(
f'* `{error["code"]}`: {error["description"]}'
for error in mint_token_payload["errors"]
)
raise exceptions.TrustedPublishingFailure(
"The token request failed; the index server gave the following "
f"reasons:\n\n{reasons}"
)

logger.debug("Minted upload token for trusted publishing")
return cast(str, mint_token_payload["token"])

@property
def system(self) -> Optional[str]:
return self.config["repository"]
Expand Down Expand Up @@ -102,12 +168,19 @@ def username_from_keyring_or_prompt(self) -> str:

return self.prompt("username", input)

def password_from_keyring_or_prompt(self) -> str:
def password_from_keyring_or_trusted_publishing_or_prompt(self) -> str:
password = self.get_password_from_keyring()
if password:
logger.info("password set from keyring")
return password

if self.is_pypi() and self.username == "__token__":
logger.debug(
"Trying to use trusted publishing (no token was explicitly provided)"
)
if (token := self.make_trusted_publishing_token()) is not None:
return token

# Prompt for API token when required.
what = "API token" if self.is_pypi() else "password"

Expand Down
1 change: 1 addition & 0 deletions twine/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def list_dependencies_and_versions() -> List[Tuple[str, str]]:
"requests",
"requests-toolbelt",
"urllib3",
"id",
]
if sys.version_info < (3, 10):
deps.append("importlib-metadata")
Expand Down
6 changes: 6 additions & 0 deletions twine/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ class NonInteractive(TwineException):
pass


class TrustedPublishingFailure(TwineException):
"""Raised if we expected to use trusted publishing but couldn't."""

pass


class InvalidPyPIUploadURL(TwineException):
"""Repository configuration tries to use PyPI with an incorrect URL.

Expand Down
Loading