import base64 import json import os import sys from http import HTTPStatus from pathlib import Path from typing import NoReturn from urllib.parse import urlparse import id # pylint: disable=redefined-builtin import requests _GITHUB_STEP_SUMMARY = Path(os.getenv('GITHUB_STEP_SUMMARY')) # The top-level error message that gets rendered. # This message wraps one of the other templates/messages defined below. _ERROR_SUMMARY_MESSAGE = """ Trusted publishing exchange failure: {message} You're seeing this because the action wasn't given the inputs needed to perform password-based or token-based authentication. If you intended to perform one of those authentication methods instead of trusted publishing, then you should double-check your secret configuration and variable names. Read more about trusted publishers at https://docs.pypi.org/trusted-publishers/ Read more about how this action uses trusted publishers at https://github.com/marketplace/actions/pypi-publish#trusted-publishing """ # Rendered if OIDC identity token retrieval fails for any reason. _TOKEN_RETRIEVAL_FAILED_MESSAGE = """ OpenID Connect token retrieval failed: {identity_error} This generally indicates a workflow configuration error, such as insufficient permissions. Make sure that your workflow has `id-token: write` configured at the job level, e.g.: ```yaml permissions: id-token: write ``` Learn more at https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings. """ # noqa: S105; not a password # Specialization of the token retrieval failure case, when we know that # the failure cause is use within a third-party PR. _TOKEN_RETRIEVAL_FAILED_FORK_PR_MESSAGE = """ OpenID Connect token retrieval failed: {identity_error} The workflow context indicates that this action was called from a pull request on a fork. GitHub doesn't give these workflows OIDC permissions, even if `id-token: write` is explicitly configured. To fix this, change your publishing workflow to use an event that forks of your repository cannot trigger (such as tag or release creation, or a manually triggered workflow dispatch). """ # noqa: S105; not a password # Rendered if the package index refuses the given OIDC token. _SERVER_REFUSED_TOKEN_EXCHANGE_MESSAGE = """ Token request failed: the server refused the request for the following reasons: {reasons} This generally indicates a trusted publisher configuration error, but could also indicate an internal error on GitHub or PyPI's part. {rendered_claims} """ # noqa: S105; not a password _RENDERED_CLAIMS = """ The claims rendered below are **for debugging purposes only**. You should **not** use them to configure a trusted publisher unless they already match your expectations. If a claim is not present in the claim set, then it is rendered as `MISSING`. * `sub`: `{sub}` * `repository`: `{repository}` * `repository_owner`: `{repository_owner}` * `repository_owner_id`: `{repository_owner_id}` * `job_workflow_ref`: `{job_workflow_ref}` * `ref`: `{ref}` See https://docs.pypi.org/trusted-publishers/troubleshooting/ for more help. """ # Rendered if the package index's token response isn't valid JSON. _SERVER_TOKEN_RESPONSE_MALFORMED_JSON = """ Token request failed: the index produced an unexpected {status_code} response. This strongly suggests a server configuration or downtime issue; wait a few minutes and try again. """ # noqa: S105; not a password # Rendered if the package index's token response isn't a valid API token payload. _SERVER_TOKEN_RESPONSE_MALFORMED_MESSAGE = """ Token response error: the index gave us an invalid response. This strongly suggests a server configuration or downtime issue; wait a few minutes and try again. """ # noqa: S105; not a password def die(msg: str) -> NoReturn: with _GITHUB_STEP_SUMMARY.open('a', encoding='utf-8') as io: print(_ERROR_SUMMARY_MESSAGE.format(message=msg), file=io) # HACK: GitHub Actions' annotations don't work across multiple lines naively; # translating `\n` into `%0A` (i.e., HTML percent-encoding) is known to work. # See: https://github.com/actions/toolkit/issues/193 msg = msg.replace('\n', '%0A') print(f'::error::Trusted publishing exchange failure: {msg}', file=sys.stderr) sys.exit(1) def debug(msg: str): print(f'::debug::{msg.title()}', file=sys.stderr) def get_normalized_input(name: str) -> str | None: name = f'INPUT_{name.upper()}' if val := os.getenv(name): return val return os.getenv(name.replace('-', '_')) def assert_successful_audience_call(resp: requests.Response, domain: str): if resp.ok: return match resp.status_code: case HTTPStatus.FORBIDDEN: # This index supports OIDC, but forbids the client from using # it (either because it's disabled, ratelimited, etc.) die( f'audience retrieval failed: repository at {domain} has trusted publishing disabled', ) case HTTPStatus.NOT_FOUND: # This index does not support OIDC. die( 'audience retrieval failed: repository at ' f'{domain} does not indicate trusted publishing support', ) case other: status = HTTPStatus(other) # Unknown: the index may or may not support OIDC, but didn't respond with # something we expect. This can happen if the index is broken, in maintenance mode, # misconfigured, etc. die( 'audience retrieval failed: repository at ' f'{domain} responded with unexpected {other}: {status.phrase}', ) def render_claims(token: str) -> str: _, payload, _ = token.split('.', 2) # urlsafe_b64decode needs padding; JWT payloads don't contain any. payload += "=" * (4 - (len(payload) % 4)) claims = json.loads(base64.urlsafe_b64decode(payload)) def _get(name: str) -> str: # noqa: WPS430 return claims.get(name, 'MISSING') return _RENDERED_CLAIMS.format( sub=_get('sub'), repository=_get('repository'), repository_owner=_get('repository_owner'), repository_owner_id=_get('repository_owner_id'), job_workflow_ref=_get('job_workflow_ref'), ref=_get('ref'), ) def event_is_third_party_pr() -> bool: # Non-`pull_request` events cannot be from third-party PRs. if os.getenv('GITHUB_EVENT_NAME') != 'pull_request': return False event_path = os.getenv('GITHUB_EVENT_PATH') if not event_path: # No GITHUB_EVENT_PATH indicates a weird GitHub or runner bug. debug('unexpected: no GITHUB_EVENT_PATH to check') return False try: event = json.loads(Path(event_path).read_bytes()) except json.JSONDecodeError: debug('unexpected: GITHUB_EVENT_PATH does not contain valid JSON') return False try: return event['pull_request']['head']['repo']['fork'] except KeyError: return False repository_url = get_normalized_input('repository-url') repository_domain = urlparse(repository_url).netloc token_exchange_url = f'https://{repository_domain}/_/oidc/mint-token' # 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' audience_resp = requests.get(audience_url) assert_successful_audience_call(audience_resp, repository_domain) oidc_audience = audience_resp.json()['audience'] debug(f'selected trusted publishing exchange endpoint: {token_exchange_url}') try: oidc_token = id.detect_credential(audience=oidc_audience) except id.IdentityError as identity_error: cause_msg_tmpl = ( _TOKEN_RETRIEVAL_FAILED_FORK_PR_MESSAGE if event_is_third_party_pr() else _TOKEN_RETRIEVAL_FAILED_MESSAGE ) for_cause_msg = cause_msg_tmpl.format(identity_error=identity_error) die(for_cause_msg) # Now we can do the actual token exchange. mint_token_resp = requests.post( token_exchange_url, json={'token': oidc_token}, ) try: mint_token_payload = mint_token_resp.json() except requests.JSONDecodeError: # Token exchange failure normally produces a JSON error response, but # we might have hit a server error instead. die( _SERVER_TOKEN_RESPONSE_MALFORMED_JSON.format( status_code=mint_token_resp.status_code, ), ) # On failure, the JSON response includes the list of errors that # occurred during minting. if not mint_token_resp.ok: reasons = '\n'.join( f'* `{error['code']}`: {error['description']}' for error in mint_token_payload['errors'] ) rendered_claims = render_claims(oidc_token) die( _SERVER_REFUSED_TOKEN_EXCHANGE_MESSAGE.format( reasons=reasons, rendered_claims=rendered_claims, ), ) pypi_token = mint_token_payload.get('token') if pypi_token is None: die(_SERVER_TOKEN_RESPONSE_MALFORMED_MESSAGE) # Mask the newly minted PyPI token, so that we don't accidentally leak it in logs. print(f'::add-mask::{pypi_token}', file=sys.stderr) # This final print will be captured by the subshell in `twine-upload.sh`. print(pypi_token)