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]

Reply via email to