This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git
The following commit(s) were added to refs/heads/main by this push:
new 4341574 Add an API endpoint to parse a GitHub OIDC JWT
4341574 is described below
commit 43415744a73e972f273518d42333f8928618b52c
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Aug 14 17:34:44 2025 +0100
Add an API endpoint to parse a GitHub OIDC JWT
---
atr/blueprints/api/api.py | 22 +++++++++++++++++++++-
atr/config.py | 1 +
atr/jwtoken.py | 13 +++++++++++++
atr/log.py | 22 ++++++++++++++++++++++
atr/models/api.py | 11 +++++++++++
pyproject.toml | 1 +
uv.lock | 22 ++++++++++++++++++++++
7 files changed, 91 insertions(+), 1 deletion(-)
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index b8c2564..fc2eda1 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -18,6 +18,7 @@
import base64
import hashlib
+import json
import pathlib
from typing import Any
@@ -36,6 +37,7 @@ import atr.config as config
import atr.db as db
import atr.db.interaction as interaction
import atr.jwtoken as jwtoken
+import atr.log as log
import atr.models as models
import atr.models.sql as sql
import atr.revision as revision
@@ -327,7 +329,6 @@ async def ignore_list(committee_name: str) -> DictResponse:
).model_dump(), 200
-# This is the only POST endpoint that does not require a JWT
@api.BLUEPRINT.route("/jwt/create", methods=["POST"])
@quart_schema.validate_request(models.api.JwtCreateArgs)
async def jwt_create(data: models.api.JwtCreateArgs) -> DictResponse:
@@ -350,6 +351,25 @@ async def jwt_create(data: models.api.JwtCreateArgs) ->
DictResponse:
).model_dump(), 200
[email protected]("/jwt/github", methods=["POST"])
+@quart_schema.validate_request(models.api.JwtGithubArgs)
+async def jwt_github(data: models.api.JwtGithubArgs) -> DictResponse:
+ """
+ Create a JWT from a GitHub OIDC JWT.
+
+ The payload must include a valid GitHub OIDC JWT.
+ """
+ # TODO: This is a placeholder for the actual implementation
+ unverified_payload = jwtoken.rs256_unverified_payload(data.jwt)
+ unverified_payload_json = json.dumps(unverified_payload).encode("utf-8")
+ log.secret("GitHub OIDC JWT payload", unverified_payload_json)
+
+ return models.api.JwtGithubResults(
+ endpoint="/jwt/github",
+ jwt="TODO",
+ ).model_dump(), 200
+
+
@api.BLUEPRINT.route("/key/add", methods=["POST"])
@jwtoken.require
@quart_schema.security_scheme([{"BearerAuth": []}])
diff --git a/atr/config.py b/atr/config.py
index 4c36173..4659594 100644
--- a/atr/config.py
+++ b/atr/config.py
@@ -44,6 +44,7 @@ class AppConfig:
STATE_DIR = decouple.config("STATE_DIR",
default=os.path.join(PROJECT_ROOT, "state"))
LDAP_BIND_DN = _config_secrets("LDAP_BIND_DN", STATE_DIR, default=None,
cast=str)
LDAP_BIND_PASSWORD = _config_secrets("LDAP_BIND_PASSWORD", STATE_DIR,
default=None, cast=str)
+ LOG_PUBLIC_KEY = _config_secrets("LOG_PUBLIC_KEY", STATE_DIR,
default=None, cast=str)
PUBSUB_URL = _config_secrets("PUBSUB_URL", STATE_DIR, default=None,
cast=str)
PUBSUB_USER = _config_secrets("PUBSUB_USER", STATE_DIR, default=None,
cast=str)
PUBSUB_PASSWORD = _config_secrets("PUBSUB_PASSWORD", STATE_DIR,
default=None, cast=str)
diff --git a/atr/jwtoken.py b/atr/jwtoken.py
index 0dacbbe..d90aae7 100644
--- a/atr/jwtoken.py
+++ b/atr/jwtoken.py
@@ -61,6 +61,19 @@ def require[**P, R](func: Callable[P, Coroutine[Any, Any,
R]]) -> Callable[P, Aw
return wrapper
+def rs256_unverified_payload(jwt_value: str) -> dict[str, Any]:
+ header = jwt.get_unverified_header(jwt_value)
+ if header != {"alg": "RS256", "typ": "JWT"}:
+ raise RuntimeError("Invalid JWT header.")
+
+ try:
+ payload = jwt.decode(jwt_value, options={"verify_signature": False})
+ except jwt.PyJWTError as e:
+ raise RuntimeError(f"Failed to decode JWT: {e}") from e
+
+ return payload
+
+
def verify(token: str) -> dict[str, Any]:
return jwt.decode(token, _SECRET_KEY, algorithms=[_ALGORITHM])
diff --git a/atr/log.py b/atr/log.py
index 3ebf608..fe88ad4 100644
--- a/atr/log.py
+++ b/atr/log.py
@@ -85,6 +85,28 @@ def log(level: int, msg: str, *args: Any, **kwargs: Any) ->
None:
_event(level, msg, *args, **kwargs)
+def secret(msg: str, data: bytes, *args: Any, **kwargs: Any) -> None:
+ import base64
+
+ import nacl.encoding as encoding
+ import nacl.public as public
+
+ import atr.config as config
+
+ conf = config.get()
+ public_key_b64 = conf.LOG_PUBLIC_KEY
+ if public_key_b64 is None:
+ raise ValueError("LOG_PUBLIC_KEY is not set")
+
+ recipient_pk = public.PublicKey(
+ public_key_b64.encode("ascii"),
+ encoder=encoding.Base64Encoder,
+ )
+ ciphertext = public.SealedBox(recipient_pk).encrypt(data)
+ encoded_ciphertext = base64.b64encode(ciphertext).decode("ascii")
+ _event(logging.INFO, f"{msg} {encoded_ciphertext}", *args, **kwargs)
+
+
def warning(msg: str, *args: Any, **kwargs: Any) -> None:
_event(logging.WARNING, msg, *args, **kwargs)
diff --git a/atr/models/api.py b/atr/models/api.py
index a0294cd..ec385cb 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -115,6 +115,15 @@ class JwtCreateResults(schema.Strict):
jwt: str = schema.Field(...,
**example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI="))
+class JwtGithubArgs(schema.Strict):
+ jwt: str = schema.Field(...,
**example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI="))
+
+
+class JwtGithubResults(schema.Strict):
+ endpoint: Literal["/jwt/github"] = schema.Field(alias="endpoint")
+ jwt: str = schema.Field(...,
**example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI="))
+
+
class KeyAddArgs(schema.Strict):
asfuid: str = schema.Field(..., **example("user"))
key: str = schema.Field(
@@ -424,6 +433,7 @@ Results = Annotated[
| IgnoreDeleteResults
| IgnoreListResults
| JwtCreateResults
+ | JwtGithubResults
| KeyAddResults
| KeyDeleteResults
| KeyGetResults
@@ -476,6 +486,7 @@ validate_ignore_add = validator(IgnoreAddResults)
validate_ignore_delete = validator(IgnoreDeleteResults)
validate_ignore_list = validator(IgnoreListResults)
validate_jwt_create = validator(JwtCreateResults)
+validate_jwt_github = validator(JwtGithubResults)
validate_key_add = validator(KeyAddResults)
validate_key_delete = validator(KeyDeleteResults)
validate_key_get = validator(KeyGetResults)
diff --git a/pyproject.toml b/pyproject.toml
index ec69c15..a504298 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,7 @@ dependencies = [
"pgpy>=0.6.0",
"pydantic-xml (>=2.17.2,<3.0.0)",
"pyjwt (>=2.10.1,<3.0.0)",
+ "pynacl>=1.5.0",
"python-decouple~=3.8",
"python-gnupg~=0.5",
"quart-schema[pydantic]~=0.21",
diff --git a/uv.lock b/uv.lock
index d16b3b1..d92b72c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1098,6 +1098,26 @@ wheels = [
{ url =
"https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl",
hash =
"sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size
= 22997, upload-time = "2024-11-28T03:43:27.893Z" },
]
+[[package]]
+name = "pynacl"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz",
hash =
"sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size
= 3392854, upload-time = "2022-01-07T22:05:41.134Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl",
hash =
"sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size
= 349920, upload-time = "2022-01-07T22:05:49.156Z" },
+ { url =
"https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl",
hash =
"sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size
= 601722, upload-time = "2022-01-07T22:05:50.989Z" },
+ { url =
"https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
hash =
"sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size
= 680087, upload-time = "2022-01-07T22:05:52.539Z" },
+ { url =
"https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl",
hash =
"sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size
= 856678, upload-time = "2022-01-07T22:05:54.251Z" },
+ { url =
"https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
hash =
"sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size
= 1133660, upload-time = "2022-01-07T22:05:56.056Z" },
+ { url =
"https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl",
hash =
"sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size
= 663824, upload-time = "2022-01-07T22:05:57.434Z" },
+ { url =
"https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl",
hash =
"sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size
= 1117912, upload-time = "2022-01-07T22:05:58.665Z" },
+ { url =
"https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl",
hash =
"sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size
= 204624, upload-time = "2022-01-07T22:06:00.085Z" },
+ { url =
"https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl",
hash =
"sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size
= 212141, upload-time = "2022-01-07T22:06:01.861Z" },
+]
+
[[package]]
name = "pyright"
version = "1.1.403"
@@ -1404,6 +1424,7 @@ dependencies = [
{ name = "pgpy" },
{ name = "pydantic-xml" },
{ name = "pyjwt" },
+ { name = "pynacl" },
{ name = "python-decouple" },
{ name = "python-gnupg" },
{ name = "quart-schema", extra = ["pydantic"] },
@@ -1454,6 +1475,7 @@ requires-dist = [
{ name = "pgpy", specifier = ">=0.6.0" },
{ name = "pydantic-xml", specifier = ">=2.17.2,<3.0.0" },
{ name = "pyjwt", specifier = ">=2.10.1,<3.0.0" },
+ { name = "pynacl", specifier = ">=1.5.0" },
{ name = "python-decouple", specifier = "~=3.8" },
{ name = "python-gnupg", specifier = "~=0.5" },
{ name = "quart-schema", extras = ["pydantic"], specifier = "~=0.21" },
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]