-
-
Notifications
You must be signed in to change notification settings - Fork 100
OIDC exchange support #123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -62,6 +62,51 @@ The secret used in `${{ secrets.PYPI_API_TOKEN }}` needs to be created on the | |
settings page of your project on GitHub. See [Creating & using secrets]. | ||
|
||
woodruffw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
### Publishing with OpenID Connect | ||
|
||
> **IMPORTANT**: This functionality is in beta, and will not work for you | ||
> unless you're a member of the PyPI OIDC beta testers' group. For more | ||
> information, see [warehouse#12965]. | ||
|
||
This action supports PyPI's [OpenID Connect publishing] | ||
implementation, which allows authentication to PyPI without a manually | ||
configured API token or username/password combination. To perform | ||
[OIDC publishing][OpenID Connect Publishing] with this action, your project's | ||
OIDC publisher must already be configured on PyPI. | ||
|
||
To enter the OIDC flow, configure this action's job with the `id-token: write` | ||
permission and **without** an explicit username or password: | ||
|
||
```yaml | ||
jobs: | ||
pypi-publish: | ||
name: Upload release to PyPI | ||
runs-on: ubuntu-latest | ||
permissions: | ||
id-token: write # IMPORTANT: this permission is mandatory for OIDC publishing | ||
steps: | ||
# retrieve your distributions here | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note to myself: a part of me wants to showcase using something like |
||
|
||
- name: Publish package distributions to PyPI | ||
uses: pypa/gh-action-pypi-publish@release/v1 | ||
``` | ||
|
||
Other indices that support OIDC publishing can also be used, like TestPyPI: | ||
|
||
```yaml | ||
- name: Publish package distributions to TestPyPI | ||
uses: pypa/gh-action-pypi-publish@release/v1 | ||
with: | ||
repository-url: https://test.pypi.org/legacy/ | ||
``` | ||
|
||
woodruffw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
> **Pro tip**: only set the `id-token: write` permission in the job that does | ||
> publishing, not globally. Also, try to separate building from publishing | ||
> — this makes sure that any scripts maliciously injected into the build | ||
> or test environment won't be able to elevate privileges while flying under | ||
> the radar. | ||
|
||
|
||
## Non-goals | ||
|
||
This GitHub Action [has nothing to do with _building package | ||
|
@@ -221,3 +266,6 @@ https://packaging.python.org/glossary/#term-Distribution-Package | |
https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg | ||
[SWUdocs]: | ||
https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md | ||
|
||
[warehouse#12965]: https://github.com/pypi/warehouse/issues/12965 | ||
[OpenID Connect Publishing]: https://pypi.org/help/#openid-connect |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,6 +20,7 @@ inputs: | |
The inputs have been normalized to use kebab-case. | ||
Use `repository-url` instead. | ||
required: false | ||
default: https://pypi.org/legacy/ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @woodruffw apparently, this caused a regression because PyPI itself has a special-cased upload URL which I forgot about — #130. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (fixed in v1.8.1) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good to know, thanks for fixing and sorry for the regression 🙂 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No worries, it was my oversight... |
||
packages-dir: # Canonical alias for `packages_dir` | ||
description: The target directory for distribution | ||
required: false | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,156 @@ | ||
import os | ||
woodruffw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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. | ||
""" | ||
woodruffw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
# 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 | ||
webknjaz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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) |
Uh oh!
There was an error while loading. Please reload this page.