|
| 1 | +import os |
| 2 | +import sys |
| 3 | +from http import HTTPStatus |
| 4 | +from pathlib import Path |
| 5 | +from typing import NoReturn |
| 6 | +from urllib.parse import urlparse |
| 7 | + |
| 8 | +import id # pylint: disable=redefined-builtin |
| 9 | +import requests |
| 10 | + |
| 11 | +_GITHUB_STEP_SUMMARY = Path(os.getenv("GITHUB_STEP_SUMMARY")) |
| 12 | + |
| 13 | +# Rendered if OIDC identity token retrieval fails for any reason. |
| 14 | +_TOKEN_RETRIEVAL_FAILED_MESSAGE = """ |
| 15 | +OIDC token retrieval failed: {identity_error} |
| 16 | +
|
| 17 | +This generally indicates a workflow configuration error, such as insufficient |
| 18 | +permissions. Make sure that your workflow has `id-token: write` configured |
| 19 | +at the job level, e.g.: |
| 20 | +
|
| 21 | +```yaml |
| 22 | +permissions: |
| 23 | + id-token: write |
| 24 | +``` |
| 25 | +
|
| 26 | +Learn more at https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings. |
| 27 | +""" |
| 28 | + |
| 29 | +# Rendered if the package index refuses the given OIDC token. |
| 30 | +_SERVER_REFUSED_TOKEN_EXCHANGE_MESSAGE = """ |
| 31 | +Token request failed: the server refused the request for the following reasons: |
| 32 | +
|
| 33 | +{reasons} |
| 34 | +""" |
| 35 | + |
| 36 | +# Rendered if the package index's token response isn't valid JSON. |
| 37 | +_SERVER_TOKEN_RESPONSE_MALFORMED_JSON = """ |
| 38 | +Token request failed: the index produced an unexpected |
| 39 | +{status_code} response. |
| 40 | +
|
| 41 | +This strongly suggests a server configuration or downtime issue; wait |
| 42 | +a few minutes and try again. |
| 43 | +""" |
| 44 | + |
| 45 | +# Rendered if the package index's token response isn't a valid API token payload. |
| 46 | +_SERVER_TOKEN_RESPONSE_MALFORMED_MESSAGE = """ |
| 47 | +Token response error: the index gave us an invalid response. |
| 48 | +
|
| 49 | +This strongly suggests a server configuration or downtime issue; wait |
| 50 | +a few minutes and try again. |
| 51 | +""" |
| 52 | + |
| 53 | + |
| 54 | +def die(msg: str) -> NoReturn: |
| 55 | + with _GITHUB_STEP_SUMMARY.open("a", encoding="utf-8") as io: |
| 56 | + print(msg, file=io) |
| 57 | + |
| 58 | + # NOTE: `msg` is Markdown formatted, so we emit only the header line to |
| 59 | + # avoid clogging the console log with a full Markdown formatted document. |
| 60 | + header = msg.splitlines()[0] |
| 61 | + print(f"::error::OIDC exchange failure: {header}", file=sys.stderr) |
| 62 | + sys.exit(1) |
| 63 | + |
| 64 | + |
| 65 | +def debug(msg: str): |
| 66 | + print(f"::debug::{msg.title()}", file=sys.stderr) |
| 67 | + |
| 68 | + |
| 69 | +def get_normalized_input(name: str) -> str | None: |
| 70 | + name = f"INPUT_{name.upper()}" |
| 71 | + if val := os.getenv(name): |
| 72 | + return val |
| 73 | + return os.getenv(name.replace("-", "_")) |
| 74 | + |
| 75 | + |
| 76 | +def assert_successful_audience_call(resp: requests.Response, domain: str): |
| 77 | + if resp.ok: |
| 78 | + return |
| 79 | + |
| 80 | + match resp.status_code: |
| 81 | + case HTTPStatus.FORBIDDEN: |
| 82 | + # This index supports OIDC, but forbids the client from using |
| 83 | + # it (either because it's disabled, limited to a beta group, etc.) |
| 84 | + die(f"audience retrieval failed: repository at {domain} has OIDC disabled") |
| 85 | + case HTTPStatus.NOT_FOUND: |
| 86 | + # This index does not support OIDC. |
| 87 | + die( |
| 88 | + "audience retrieval failed: repository at " |
| 89 | + f"{domain} does not indicate OIDC support", |
| 90 | + ) |
| 91 | + case other: |
| 92 | + status = HTTPStatus(other) |
| 93 | + # Unknown: the index may or may not support OIDC, but didn't respond with |
| 94 | + # something we expect. This can happen if the index is broken, in maintenance mode, |
| 95 | + # misconfigured, etc. |
| 96 | + die( |
| 97 | + "audience retrieval failed: repository at " |
| 98 | + f"{domain} responded with unexpected {other}: {status.phrase}", |
| 99 | + ) |
| 100 | + |
| 101 | + |
| 102 | +repository_url = get_normalized_input("repository-url") |
| 103 | +repository_domain = urlparse(repository_url).netloc |
| 104 | +token_exchange_url = f"https://{repository_domain}/_/oidc/github/mint-token" |
| 105 | + |
| 106 | +# Indices are expected to support `https://{domain}/_/oidc/audience`, |
| 107 | +# which tells OIDC exchange clients which audience to use. |
| 108 | +audience_url = f"https://{repository_domain}/_/oidc/audience" |
| 109 | +audience_resp = requests.get(audience_url) |
| 110 | +assert_successful_audience_call(audience_resp, repository_domain) |
| 111 | + |
| 112 | +oidc_audience = audience_resp.json()["audience"] |
| 113 | + |
| 114 | +debug(f"selected OIDC token exchange endpoint: {token_exchange_url}") |
| 115 | + |
| 116 | +try: |
| 117 | + oidc_token = id.detect_credential(audience=oidc_audience) |
| 118 | +except id.IdentityError as identity_error: |
| 119 | + die(_TOKEN_RETRIEVAL_FAILED_MESSAGE.format(identity_error=identity_error)) |
| 120 | + |
| 121 | +# Now we can do the actual token exchange. |
| 122 | +mint_token_resp = requests.post( |
| 123 | + token_exchange_url, |
| 124 | + json={"token": oidc_token}, |
| 125 | +) |
| 126 | + |
| 127 | +try: |
| 128 | + mint_token_payload = mint_token_resp.json() |
| 129 | +except requests.JSONDecodeError: |
| 130 | + # Token exchange failure normally produces a JSON error response, but |
| 131 | + # we might have hit a server error instead. |
| 132 | + die( |
| 133 | + _SERVER_TOKEN_RESPONSE_MALFORMED_JSON.format( |
| 134 | + status_code=mint_token_resp.status_code, |
| 135 | + ), |
| 136 | + ) |
| 137 | + |
| 138 | +# On failure, the JSON response includes the list of errors that |
| 139 | +# occurred during minting. |
| 140 | +if not mint_token_resp.ok: |
| 141 | + reasons = "\n".join( |
| 142 | + f"* `{error['code']}`: {error['description']}" |
| 143 | + for error in mint_token_payload["errors"] |
| 144 | + ) |
| 145 | + |
| 146 | + die(_SERVER_REFUSED_TOKEN_EXCHANGE_MESSAGE.format(reasons=reasons)) |
| 147 | + |
| 148 | +pypi_token = mint_token_payload.get("token") |
| 149 | +if pypi_token is None: |
| 150 | + die(_SERVER_TOKEN_RESPONSE_MALFORMED_MESSAGE) |
| 151 | + |
| 152 | +# Mask the newly minted PyPI token, so that we don't accidentally leak it in logs. |
| 153 | +print(f"::add-mask::{pypi_token}", file=sys.stderr) |
| 154 | + |
| 155 | +# This final print will be captured by the subshell in `twine-upload.sh`. |
| 156 | +print(pypi_token) |
0 commit comments