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 2e65712  Exempt the API from CSRF, and add a PAT to JWT endpoint
2e65712 is described below

commit 2e65712a97754cabe7f9daddb78dc0f346cd936a
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Jul 3 21:33:45 2025 +0100

    Exempt the API from CSRF, and add a PAT to JWT endpoint
---
 atr/blueprints/api/__init__.py | 19 ++++++++++++++++---
 atr/blueprints/api/api.py      | 40 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 56 insertions(+), 3 deletions(-)

diff --git a/atr/blueprints/api/__init__.py b/atr/blueprints/api/__init__.py
index b6a9bdf..f330c4c 100644
--- a/atr/blueprints/api/__init__.py
+++ b/atr/blueprints/api/__init__.py
@@ -17,26 +17,39 @@
 
 import asfquart.base as base
 import quart
+import quart.blueprints as blueprints
 import werkzeug.exceptions as exceptions
 
 BLUEPRINT = quart.Blueprint("api_blueprint", __name__, url_prefix="/api")
 
 
+def _exempt_blueprint(app: base.QuartApp) -> None:
+    csrf = app.extensions.get("csrf")
+    if csrf is not None:
+        csrf.exempt(BLUEPRINT)
+
+
 @BLUEPRINT.errorhandler(base.ASFQuartException)
-async def handle_asfquart_exception(err: base.ASFQuartException) -> 
tuple[quart.Response, int]:
+async def _handle_asfquart_exception(err: base.ASFQuartException) -> 
tuple[quart.Response, int]:
     status = getattr(err, "errorcode", 500)
     return _json_error(str(err), status)
 
 
 @BLUEPRINT.errorhandler(Exception)
-async def handle_generic_exception(err: Exception) -> tuple[quart.Response, 
int]:
+async def _handle_generic_exception(err: Exception) -> tuple[quart.Response, 
int]:
     return _json_error(str(err), 500)
 
 
 @BLUEPRINT.errorhandler(exceptions.HTTPException)
-async def handle_http_exception(err: exceptions.HTTPException) -> 
tuple[quart.Response, int]:
+async def _handle_http_exception(err: exceptions.HTTPException) -> 
tuple[quart.Response, int]:
     return _json_error(err.description or err.name, err.code)
 
 
 def _json_error(message: str, status_code: int | None) -> 
tuple[quart.Response, int]:
     return quart.jsonify({"error": message}), status_code or 500
+
+
[email protected]_once
+def _setup(state: blueprints.BlueprintSetupState) -> None:
+    if isinstance(state.app, base.QuartApp):
+        _exempt_blueprint(state.app)
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 373bcaa..e5f869c 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -16,6 +16,8 @@
 # under the License.
 
 import dataclasses
+import datetime
+import hashlib
 from collections.abc import Mapping
 
 import quart
@@ -29,6 +31,7 @@ import atr.blueprints.api as api
 import atr.db as db
 import atr.db.models as models
 import atr.jwtoken as jwtoken
+import atr.schema as schema
 
 # FIXME: we need to return the dumped model instead of the actual pydantic 
class
 #        as otherwise pyright will complain about the return type
@@ -52,6 +55,11 @@ class Task(Pagination):
     status: str | None = None
 
 
+class PATJWTRequest(schema.Strict):
+    asfuid: str
+    pat: str
+
+
 # We implicitly have /api/openapi.json
 
 
@@ -91,6 +99,28 @@ async def committees_name_projects(name: str) -> 
tuple[list[Mapping], int]:
         return [project.model_dump() for project in committee.projects], 200
 
 
[email protected]("/jwt", methods=["POST"])
+async def pat_jwt_post() -> quart.Response:
+    """Generate a JWT from a valid PAT."""
+    # Expects {"asfuid": "uid", "pat": "pat-token"}
+    # Returns {"asfuid": "uid", "jwt": "jwt-token"}
+
+    payload = await quart.request.get_json(force=True, silent=False)
+    if not isinstance(payload, dict):
+        return quart.Response("Invalid JSON", status=400)
+
+    pat_request = PATJWTRequest.model_validate(payload)
+    token_hash = hashlib.sha3_256(pat_request.pat.encode()).hexdigest()
+    pat_rec = await _get_pat(pat_request.asfuid, token_hash)
+
+    now = datetime.datetime.now(datetime.UTC)
+    if (pat_rec is None) or (pat_rec.expires < now):
+        return quart.Response("Invalid PAT", status=401)
+
+    jwt_token = jwtoken.issue(pat_request.asfuid)
+    return quart.jsonify({"asfuid": pat_request.asfuid, "jwt": jwt_token})
+
+
 @api.BLUEPRINT.route("/keys")
 @quart_schema.validate_querystring(Pagination)
 async def public_keys(query_args: Pagination) -> quart.Response:
@@ -263,6 +293,16 @@ async def tasks(query_args: Task) -> quart.Response:
         return quart.jsonify(result)
 
 
[email protected]_function
+async def _get_pat(data: db.Session, uid: str, token_hash: str) -> 
models.PersonalAccessToken | None:
+    return await data.query_one_or_none(
+        sqlmodel.select(models.PersonalAccessToken).where(
+            models.PersonalAccessToken.asfuid == uid,
+            models.PersonalAccessToken.token_hash == token_hash,
+        )
+    )
+
+
 def _pagination_args_validate(query_args: Pagination) -> None:
     # Users could request any amount using limit=N with arbitrarily high N
     # We therefore limit the maximum limit to 1000


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to