|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +import argparse |
| 4 | +import base64 |
| 5 | +import json |
| 6 | +import os |
| 7 | +import sys |
| 8 | + |
| 9 | +import id # pylint: disable=redefined-builtin |
| 10 | +import requests |
| 11 | + |
| 12 | + |
| 13 | +def _fatal(message: str) -> None: |
| 14 | + # HACK: GitHub Actions' annotations don't work across multiple lines naively; |
| 15 | + # translating `\n` into `%0A` (i.e., HTML percent-encoding) is known to work. |
| 16 | + # See: https://github.com/actions/toolkit/issues/193 |
| 17 | + message = message.replace("\n", "%0A") |
| 18 | + print(f"::error::Trusted publishing exchange failure: {message}", file=sys.stderr) |
| 19 | + sys.exit(1) |
| 20 | + |
| 21 | + |
| 22 | +def _debug(message: str): |
| 23 | + print(f"::debug::{message.title()}", file=sys.stderr) |
| 24 | + |
| 25 | + |
| 26 | +def _render_claims(token: str) -> str: |
| 27 | + _, payload, _ = token.split(".", 2) |
| 28 | + |
| 29 | + # urlsafe_b64decode needs padding; JWT payloads don't contain any. |
| 30 | + payload += "=" * (4 - (len(payload) % 4)) |
| 31 | + claims = json.loads(base64.urlsafe_b64decode(payload)) |
| 32 | + |
| 33 | + return f""" |
| 34 | +The claims rendered below are **for debugging purposes only**. You should **not** |
| 35 | +use them to configure a trusted publisher unless they already match your expectations. |
| 36 | +
|
| 37 | +If a claim is not present in the claim set, then it is rendered as `MISSING`. |
| 38 | +
|
| 39 | +* `sub`: `{claims.get('sub', 'MISSING')}` |
| 40 | +* `repository`: `{claims.get('repository', 'MISSING')}` |
| 41 | +* `repository_owner`: `{claims.get('repository_owner', 'MISSING')}` |
| 42 | +* `repository_owner_id`: `{claims.get('repository_owner_id', 'MISSING')}` |
| 43 | +* `job_workflow_ref`: `{claims.get('job_workflow_ref', 'MISSING')}` |
| 44 | +* `ref`: `{claims.get('ref')}` |
| 45 | +
|
| 46 | +See https://docs.pypi.org/trusted-publishers/troubleshooting/ for more help. |
| 47 | +""" |
| 48 | + |
| 49 | + |
| 50 | +def _get_token() -> str: |
| 51 | + # Indices are expected to support `https://pypi.org/_/oidc/audience`, |
| 52 | + # which tells OIDC exchange clients which audience to use. |
| 53 | + audience_resp = requests.get("https://pypi.org/_/oidc/audience", timeout=5) |
| 54 | + audience_resp.raise_for_status() |
| 55 | + |
| 56 | + _debug(f"selected trusted publishing exchange endpoint: https://pypi.org/_/oidc/mint-token") |
| 57 | + |
| 58 | + try: |
| 59 | + oidc_token = id.detect_credential(audience=audience_resp.json()["audience"]) |
| 60 | + except id.IdentityError as identity_error: |
| 61 | + _fatal( |
| 62 | + f""" |
| 63 | +OpenID Connect token retrieval failed: {identity_error} |
| 64 | +
|
| 65 | +This generally indicates a workflow configuration error, such as insufficient |
| 66 | +permissions. Make sure that your workflow has `id-token: write` configured |
| 67 | +at the job level, e.g.: |
| 68 | +
|
| 69 | +```yaml |
| 70 | +permissions: |
| 71 | + id-token: write |
| 72 | +``` |
| 73 | +
|
| 74 | +Learn more at https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings. |
| 75 | +""" |
| 76 | + ) |
| 77 | + |
| 78 | + # Now we can do the actual token exchange. |
| 79 | + mint_token_resp = requests.post( |
| 80 | + "https://pypi.org/_/oidc/mint-token", |
| 81 | + json={"token": oidc_token}, |
| 82 | + timeout=5, |
| 83 | + ) |
| 84 | + |
| 85 | + try: |
| 86 | + mint_token_payload = mint_token_resp.json() |
| 87 | + except requests.JSONDecodeError: |
| 88 | + # Token exchange failure normally produces a JSON error response, but |
| 89 | + # we might have hit a server error instead. |
| 90 | + _fatal( |
| 91 | + f""" |
| 92 | +Token request failed: the index produced an unexpected |
| 93 | +{mint_token_resp.status_code} response. |
| 94 | +
|
| 95 | +This strongly suggests a server configuration or downtime issue; wait |
| 96 | +a few minutes and try again. |
| 97 | +
|
| 98 | +You can monitor PyPI's status here: https://status.python.org/ |
| 99 | +""" |
| 100 | + ) |
| 101 | + |
| 102 | + # On failure, the JSON response includes the list of errors that |
| 103 | + # occurred during minting. |
| 104 | + if not mint_token_resp.ok: |
| 105 | + reasons = "\n".join( |
| 106 | + f'* `{error["code"]}`: {error["description"]}' for error in mint_token_payload["errors"] |
| 107 | + ) |
| 108 | + |
| 109 | + rendered_claims = _render_claims(oidc_token) |
| 110 | + |
| 111 | + _fatal( |
| 112 | + f""" |
| 113 | +Token request failed: the server refused the request for the following reasons: |
| 114 | +
|
| 115 | +{reasons} |
| 116 | +
|
| 117 | +This generally indicates a trusted publisher configuration error, but could |
| 118 | +also indicate an internal error on GitHub or PyPI's part. |
| 119 | +
|
| 120 | +{rendered_claims} |
| 121 | +""" |
| 122 | + ) |
| 123 | + |
| 124 | + pypi_token = mint_token_payload.get("token") |
| 125 | + if pypi_token is None: |
| 126 | + _fatal( |
| 127 | + """ |
| 128 | +Token response error: the index gave us an invalid response. |
| 129 | +
|
| 130 | +This strongly suggests a server configuration or downtime issue; wait |
| 131 | +a few minutes and try again. |
| 132 | +""" |
| 133 | + ) |
| 134 | + |
| 135 | + # Mask the newly minted PyPI token, so that we don't accidentally leak it in logs. |
| 136 | + print(f"::add-mask::{pypi_token}") |
| 137 | + |
| 138 | + # This final print will be captured by the subshell in `twine-upload.sh`. |
| 139 | + return pypi_token |
| 140 | + |
| 141 | + |
| 142 | +def _main(): |
| 143 | + parser = argparse.ArgumentParser(description="Login zo pypi.org using OIDC") |
| 144 | + parser.parse_args() |
| 145 | + |
| 146 | + if "ACTIONS_ID_TOKEN_REQUEST_TOKEN" not in os.environ: |
| 147 | + print( |
| 148 | + """Not available, you probably miss the permission `id-token: write`. |
| 149 | + See also: https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect""" |
| 150 | + ) |
| 151 | + |
| 152 | + with open(os.path.expanduser("~/.pypirc"), "w", encoding="utf-8") as f: |
| 153 | + f.write("[pypi]\n") |
| 154 | + f.write("repository: https://upload.pypi.org/legacy/\n") |
| 155 | + f.write("username: __token__\n") |
| 156 | + f.write(f"password: {_get_token()}\n") |
| 157 | + |
| 158 | + |
| 159 | +if __name__ == "__main__": |
| 160 | + _main() |
0 commit comments