11import functools
22import getpass
3+ import json
34import logging
45from typing import TYPE_CHECKING , Callable , Optional , Type , cast
6+ from urllib .parse import urlparse
7+
8+ from id import AmbientCredentialError # type: ignore
9+ from id import detect_credential
510
611# keyring has an indirect dependency on PyCA cryptography, which has no
712# pre-built wheels for ppc64le and s390x, see #1158.
813if TYPE_CHECKING :
914 import keyring
15+ from keyring .errors import NoKeyringError
1016else :
1117 try :
1218 import keyring
19+ from keyring .errors import NoKeyringError
1320 except ModuleNotFoundError : # pragma: no cover
1421 keyring = None
22+ NoKeyringError = None
1523
1624from twine import exceptions
1725from twine import utils
@@ -28,7 +36,11 @@ def __init__(
2836
2937
3038class Resolver :
31- def __init__ (self , config : utils .RepositoryConfig , input : CredentialInput ) -> None :
39+ def __init__ (
40+ self ,
41+ config : utils .RepositoryConfig ,
42+ input : CredentialInput ,
43+ ) -> None :
3244 self .config = config
3345 self .input = input
3446
@@ -57,9 +69,65 @@ def password(self) -> Optional[str]:
5769 self .input .password ,
5870 self .config ,
5971 key = "password" ,
60- prompt_strategy = self .password_from_keyring_or_prompt ,
72+ prompt_strategy = self .password_from_keyring_or_trusted_publishing_or_prompt ,
6173 )
6274
75+ def make_trusted_publishing_token (self ) -> Optional [str ]:
76+ # Trusted publishing (OpenID Connect): get one token from the CI
77+ # system, and exchange that for a PyPI token.
78+ repository_domain = cast (str , urlparse (self .system ).netloc )
79+ session = utils .make_requests_session ()
80+
81+ # Indices are expected to support `https://{domain}/_/oidc/audience`,
82+ # which tells OIDC exchange clients which audience to use.
83+ audience_url = f"https://{ repository_domain } /_/oidc/audience"
84+ resp = session .get (audience_url , timeout = 5 )
85+ resp .raise_for_status ()
86+ audience = cast (str , resp .json ()["audience" ])
87+
88+ try :
89+ oidc_token = detect_credential (audience )
90+ except AmbientCredentialError as e :
91+ # If we get here, we're on a supported CI platform for trusted
92+ # publishing, and we have not been given any token, so we can error.
93+ raise exceptions .TrustedPublishingFailure (
94+ "Unable to retrieve an OIDC token from the CI platform for "
95+ f"trusted publishing { e } "
96+ )
97+
98+ if oidc_token is None :
99+ logger .debug ("This environment is not supported for trusted publishing" )
100+ return None # Fall back to prompting for a token (if possible)
101+
102+ logger .debug ("Got OIDC token for audience %s" , audience )
103+
104+ token_exchange_url = f"https://{ repository_domain } /_/oidc/mint-token"
105+
106+ mint_token_resp = session .post (
107+ token_exchange_url ,
108+ json = {"token" : oidc_token },
109+ timeout = 5 , # S113 wants a timeout
110+ )
111+ try :
112+ mint_token_payload = mint_token_resp .json ()
113+ except json .JSONDecodeError :
114+ raise exceptions .TrustedPublishingFailure (
115+ "The token-minting request returned invalid JSON"
116+ )
117+
118+ if not mint_token_resp .ok :
119+ reasons = "\n " .join (
120+ f'* `{ error ["code" ]} `: { error ["description" ]} '
121+ for error in mint_token_payload ["errors" ]
122+ )
123+ raise exceptions .TrustedPublishingFailure (
124+ "The token request failed; the index server gave the following "
125+ f"reasons:\n \n { reasons } "
126+ )
127+
128+ logger .debug ("Minted upload token for trusted publishing" )
129+ return cast (str , mint_token_payload ["token" ])
130+
63131 @property
64132 def system (self ) -> Optional [str ]:
65133 return self .config ["repository" ]
@@ -90,6 +158,8 @@ def get_password_from_keyring(self) -> Optional[str]:
90158 username = cast (str , self .username )
91159 logger .info ("Querying keyring for password" )
92160 return cast (str , keyring .get_password (system , username ))
161+ except NoKeyringError :
162+ logger .info ("No keyring backend found" )
93163 except Exception as exc :
94164 logger .warning ("Error getting password from keyring" , exc_info = exc )
95165 return None
@@ -102,12 +172,19 @@ def username_from_keyring_or_prompt(self) -> str:
102172
103173 return self .prompt ("username" , input )
104174
105- def password_from_keyring_or_prompt (self ) -> str :
175+ def password_from_keyring_or_trusted_publishing_or_prompt (self ) -> str :
106176 password = self .get_password_from_keyring ()
107177 if password :
108178 logger .info ("password set from keyring" )
109179 return password
110180
181+ if self .is_pypi () and self .username == "__token__" :
182+ logger .debug (
183+ "Trying to use trusted publishing (no token was explicitly provided)"
184+ )
185+ if (token := self .make_trusted_publishing_token ()) is not None :
186+ return token
187+
111188 # Prompt for API token when required.
112189 what = "API token" if self .is_pypi () else "password"
113190
0 commit comments