-
-
Notifications
You must be signed in to change notification settings - Fork 90
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
This patch implements support for secret-less OIDC-based publishing to PyPI-like package indexes. The OIDC flow is activated when neither username, nor password action inputs are set. The OIDC "token exchange," is an authentication technique that PyPI (and TestPyPI, and hopefully some future others) supports as an alternative to long-lived username/password combinations or API tokens. OIDC token exchange boils down to the following set of steps: 1. A user (currently only someone in the OIDC beta on PyPI) configured a particular GitHub Actions workflow in their repository as a trusted OIDC publisher; 2. That workflow uses this action to mint an OIDC token; 3. That OIDC token is sent to PyPI (or another index), which exchanges it for a temporary API token; 4. That API token is used as normal. For the seamless configuration-free upload to work, the end-users are expected to explicitly assign the `id-token: write` privilege to the auto-injected `GITHUB_TOKEN` secret on the job level. They should also set up GHA workflow trust on the PyPI side. PyPI's documentation: https://pypi.org/help/#openid-connect Beta test enrollment: pypi/warehouse#12965
- Loading branch information
Showing
8 changed files
with
234 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,156 @@ | ||
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")) | ||
|
||
# Rendered if OIDC identity token retrieval fails for any reason. | ||
_TOKEN_RETRIEVAL_FAILED_MESSAGE = """ | ||
OIDC 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. | ||
""" | ||
|
||
# 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} | ||
""" | ||
|
||
# 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. | ||
""" | ||
|
||
# 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. | ||
""" | ||
|
||
|
||
def die(msg: str) -> NoReturn: | ||
with _GITHUB_STEP_SUMMARY.open("a", encoding="utf-8") as io: | ||
print(msg, file=io) | ||
|
||
# NOTE: `msg` is Markdown formatted, so we emit only the header line to | ||
# avoid clogging the console log with a full Markdown formatted document. | ||
header = msg.splitlines()[0] | ||
print(f"::error::OIDC exchange failure: {header}", 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, limited to a beta group, etc.) | ||
die(f"audience retrieval failed: repository at {domain} has OIDC disabled") | ||
case HTTPStatus.NOT_FOUND: | ||
# This index does not support OIDC. | ||
die( | ||
"audience retrieval failed: repository at " | ||
f"{domain} does not indicate OIDC 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}", | ||
) | ||
|
||
|
||
repository_url = get_normalized_input("repository-url") | ||
repository_domain = urlparse(repository_url).netloc | ||
token_exchange_url = f"https://{repository_domain}/_/oidc/github/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 OIDC token exchange endpoint: {token_exchange_url}") | ||
|
||
try: | ||
oidc_token = id.detect_credential(audience=oidc_audience) | ||
except id.IdentityError as identity_error: | ||
die(_TOKEN_RETRIEVAL_FAILED_MESSAGE.format(identity_error=identity_error)) | ||
|
||
# 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"] | ||
) | ||
|
||
die(_SERVER_REFUSED_TOKEN_EXCHANGE_MESSAGE.format(reasons=reasons)) | ||
|
||
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters