Skip to content

Commit 623f631

Browse files
authored
Merge pull request #91 from camptocamp/oidc-login
Add pypi login with OIDC
2 parents f158927 + 1ebdef7 commit 623f631

File tree

3 files changed

+167
-0
lines changed

3 files changed

+167
-0
lines changed

.github/pypi-login

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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()

.github/workflows/main.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ jobs:
8787
timeout-minutes: 15
8888
needs: main
8989

90+
permissions:
91+
id-token: write
92+
9093
steps:
9194
- uses: actions/checkout@v4
9295

@@ -99,5 +102,7 @@ jobs:
99102
- run: echo "${HOME}/.local/bin" >> ${GITHUB_PATH}
100103
- run: python3 -m pip install --user --requirement=ci/requirements.txt
101104

105+
- name: Login with IODC (OpenID Connect)
106+
run: .github/pypi-login
102107
- name: Publish
103108
run: c2cciutils-publish

ci/requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ poetry-plugin-export==1.8.0
55
poetry-plugin-tweak-dependencies-version==1.5.2
66
poetry-plugin-drop-python-upper-constraint==0.1.0
77
pre-commit==3.8.0
8+
# OIDC (OpenID connect)
9+
id==1.4.0

0 commit comments

Comments
 (0)