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]