This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/sbp by this push:
     new 67efe112 Add an admin route to invalidate all JWTs by rotating the 
signing key
67efe112 is described below

commit 67efe112bbaf2ed001d422b10727df83434547b6
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Mar 5 20:08:09 2026 +0000

    Add an admin route to invalidate all JWTs by rotating the signing key
---
 atr/admin/__init__.py              | 127 +++++++++++++++++++++++++++++++++++--
 atr/config.py                      |   9 ++-
 atr/jwtoken.py                     |  77 +++++++++++++++++++++-
 atr/server.py                      |  10 +++
 atr/storage/writers/tokens.py      |   9 +++
 atr/templates/includes/topnav.html |  10 +++
 6 files changed, 230 insertions(+), 12 deletions(-)

diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index b877005d..3d6a034c 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -18,6 +18,7 @@
 import asyncio
 import collections
 import datetime
+import json
 import os
 import pathlib
 import statistics
@@ -31,6 +32,7 @@ import asfquart
 import asfquart.base as base
 import asfquart.session
 import htpy
+import jwt
 import pydantic
 import quart
 import sqlalchemy
@@ -44,6 +46,7 @@ import atr.db.interaction as interaction
 import atr.form as form
 import atr.get as get
 import atr.htm as htm
+import atr.jwtoken as jwtoken
 import atr.ldap as ldap
 import atr.log as log
 import atr.mapping as mapping
@@ -93,6 +96,22 @@ class RevokeUserTokensForm(form.Form):
     confirm_revoke: Literal["REVOKE"] = form.label("Confirmation", "Type 
REVOKE to confirm.")
 
 
+class RotateJwtKeyForm(form.Form):
+    confirm_rotate: Literal["ROTATE"] = form.label("Confirmation", "Type 
ROTATE to confirm.")
+
+
+class ValidateJwtForm(form.Form):
+    token: str = form.label("JWT", "Paste the JWT to validate.", 
widget=form.Widget.TEXTAREA)
+
+    @pydantic.field_validator("token")
+    @classmethod
+    def validate_token(cls, value: str) -> str:
+        token = value.strip()
+        if token == "":
+            raise ValueError("JWT is required")
+        return token
+
+
 class SessionDataCommon(NamedTuple):
     uid: str
     fullname: str
@@ -566,10 +585,7 @@ async def ldap_post(session: web.Committer, lookup_form: 
LdapLookupForm) -> str:
 
 @admin.get("/logs")
 async def logs(session: web.Committer) -> web.QuartResponse:
-    conf = config.get()
-    debug_and_allow_tests = (config.get_mode() == config.Mode.Debug) and 
conf.ALLOW_TESTS
-    if not debug_and_allow_tests:
-        raise base.ASFQuartException("Not available without ALLOW_TESTS", 
errorcode=403)
+    _require_debug_and_allow_tests()
     recent_logs = log.get_recent_logs()
     if recent_logs is None:
         raise base.ASFQuartException("Debug logging not initialised", 
errorcode=404)
@@ -752,6 +768,25 @@ async def revoke_user_tokens_post(
     return await session.redirect(revoke_user_tokens_get)
 
 
[email protected]("/rotate-jwt-key")
+async def rotate_jwt_key_get(session: web.Committer) -> str:
+    rendered_form = form.render(
+        model_cls=RotateJwtKeyForm,
+        submit_label="Rotate JWT key",
+    )
+    return await _rotate_jwt_key_page(rendered_form)
+
+
[email protected]("/rotate-jwt-key")
[email protected](RotateJwtKeyForm)
+async def rotate_jwt_key_post(session: web.Committer, _rotate_form: 
RotateJwtKeyForm) -> str | web.WerkzeugResponse:
+    async with storage.write(session) as write:
+        wafa = write.as_foundation_admin()
+        await wafa.tokens.rotate_jwt_signing_key()
+    await quart.flash("Rotated the JWT signing key. All existing JWTs are now 
invalid.", "success")
+    return await session.redirect(rotate_jwt_key_get)
+
+
 @admin.get("/task-times/<project_name>/<version_name>/<revision_number>")
 async def task_times(
     session: web.Committer, project_name: str, version_name: str, 
revision_number: str
@@ -929,6 +964,52 @@ async def validate_(session: web.Committer) -> str:
     )
 
 
[email protected]("/validate-jwt")
+async def validate_jwt_get(session: web.Committer) -> str:
+    _require_debug_and_allow_tests()
+    rendered_form = form.render(
+        model_cls=ValidateJwtForm,
+        submit_label="Validate JWT",
+        textarea_rows=8,
+    )
+    return await _validate_jwt_page(rendered_form, result=None)
+
+
[email protected]("/validate-jwt")
[email protected](ValidateJwtForm)
+async def validate_jwt_post(session: web.Committer, validate_form: 
ValidateJwtForm) -> str:
+    _require_debug_and_allow_tests()
+    token = validate_form.token
+    result: dict[str, Any] = {"token_length": len(token), "valid": False}
+
+    try:
+        result["header"] = jwt.get_unverified_header(token)
+    except jwt.PyJWTError as exc:
+        result["header_error"] = f"{type(exc).__name__}: {exc}"
+
+    try:
+        result["claims_unverified"] = jwt.decode(token, 
options={"verify_signature": False}, algorithms=["HS256"])
+    except jwt.PyJWTError as exc:
+        result["claims_unverified_error"] = f"{type(exc).__name__}: {exc}"
+
+    try:
+        result["claims_verified"] = await jwtoken.verify(token)
+        result["valid"] = True
+    except Exception as exc:
+        result["validation_error"] = {
+            "type": type(exc).__name__,
+            "message": str(exc),
+        }
+
+    rendered_form = form.render(
+        model_cls=ValidateJwtForm,
+        submit_label="Validate JWT",
+        textarea_rows=8,
+        defaults={"token": token},
+    )
+    return await _validate_jwt_page(rendered_form, result=result)
+
+
 async def _check_keys(fix: bool = False) -> str:
     email_to_uid = await util.email_to_uid_map()
     bad_keys = []
@@ -1094,6 +1175,26 @@ async def _ongoing_tasks(
         return web.TextResponse("")
 
 
+def _require_debug_and_allow_tests() -> None:
+    conf = config.get()
+    debug_and_allow_tests = (config.get_mode() == config.Mode.Debug) and 
conf.ALLOW_TESTS
+    if not debug_and_allow_tests:
+        raise base.ASFQuartException("Not available without ALLOW_TESTS", 
errorcode=403)
+
+
+async def _rotate_jwt_key_page(rendered_form: htm.Element) -> str:
+    page = htm.Block()
+    page.h1["Rotate JWT key"]
+    page.p["Rotate the JWT signing key immediately. This will invalidate all 
currently usable JWTs."]
+
+    page.append(rendered_form)
+
+    return await template.blank(
+        title="Rotate JWT key",
+        content=page.collect(),
+    )
+
+
 async def _update_keys(asf_uid: str) -> int:
     async def _log_process(process: asyncio.subprocess.Process) -> None:
         try:
@@ -1129,3 +1230,21 @@ async def _update_keys(asf_uid: str) -> int:
     task.add_done_callback(app.background_tasks.discard)
 
     return process.pid
+
+
+async def _validate_jwt_page(rendered_form: htm.Element, result: dict[str, 
Any] | None) -> str:
+    page = htm.Block()
+    page.h1["Validate JWT"]
+    page.p["Paste a JWT below to inspect unverified details and full 
verification results."]
+
+    page.append(rendered_form)
+
+    if result is not None:
+        page.h2["Result"]
+        result_text = json.dumps(result, indent=2, sort_keys=True, default=str)
+        page.pre[htm.code[result_text]]
+
+    return await template.blank(
+        title="Validate JWT",
+        content=page.collect(),
+    )
diff --git a/atr/config.py b/atr/config.py
index 114bbbc7..dcce98ff 100644
--- a/atr/config.py
+++ b/atr/config.py
@@ -17,7 +17,6 @@
 
 import enum
 import os
-import secrets
 from typing import Final
 
 import decouple
@@ -91,13 +90,13 @@ class AppConfig:
     DEBUG = False
     TEMPLATES_AUTO_RELOAD = False
     USE_BLOCKBUSTER = False
-    JWT_SECRET_KEY = _config_secrets("JWT_SECRET_KEY", STATE_DIR, 
default=None, cast=str) or secrets.token_hex(256 // 8)
-    # We no longer support SECRET_KEY
-    # We continue to read the value to print a migration warning
-    # We are now relying on apptoken.txt from ASFQuart instead
+    # We no longer support SECRET_KEY or JWT_SECRET_KEY
+    # We continue to read both values to print migration warnings
+    # For SECRET_KEY we are now relying on apptoken.txt from ASFQuart instead
     # By default, apptoken.txt is a 256 bit random value
     # ASFQuart generates it using secrets.token_hex()
     SECRET_KEY = _config_secrets("SECRET_KEY", STATE_DIR, default=None, 
cast=str)
+    JWT_SECRET_KEY = _config_secrets("JWT_SECRET_KEY", STATE_DIR, 
default=None, cast=str)
     DOWNLOADS_STORAGE_DIR = os.path.join(STATE_DIR, "downloads")
     FINISHED_STORAGE_DIR = os.path.join(STATE_DIR, "finished")
     UNFINISHED_STORAGE_DIR = os.path.join(STATE_DIR, "unfinished")
diff --git a/atr/jwtoken.py b/atr/jwtoken.py
index 63df7e14..be56b438 100644
--- a/atr/jwtoken.py
+++ b/atr/jwtoken.py
@@ -19,10 +19,13 @@ from __future__ import annotations
 
 import datetime as datetime
 import functools
+import os
+import pathlib
 import secrets as secrets
 from typing import TYPE_CHECKING, Any, Final
 
 import aiohttp
+import asfquart
 import asfquart.base as base
 import jwt
 import quart
@@ -45,12 +48,22 @@ _GITHUB_OIDC_EXPECTED: Final[dict[str, str]] = {
     "runner_environment": "github-hosted",
 }
 _GITHUB_OIDC_ISSUER: Final[str] = "https://token.actions.githubusercontent.com";
-_JWT_SECRET_KEY: Final[str] = config.get().JWT_SECRET_KEY
+_JWT_KEY_APP_EXTENSION: Final[str] = "jwt_secret_key"
+_JWT_KEY_PATH: Final[pathlib.Path] = 
pathlib.Path("secrets/generated/jwt_secret_key.txt")
+_JWT_KEY_TMP_PATH: Final[pathlib.Path] = 
pathlib.Path("secrets/generated/jwt_secret_key.txt.tmp")
+_JWT_KEY_HEX_LENGTH: Final[int] = (256 // 8) * 2
 
 if TYPE_CHECKING:
     from collections.abc import Awaitable, Callable, Coroutine
 
 
+def activate_signing_key(key: str) -> None:
+    app = asfquart.APP
+    if app is None:
+        raise RuntimeError("Application is not initialised")
+    app.extensions[_JWT_KEY_APP_EXTENSION] = key
+
+
 def issue(uid: str, *, ttl: int = _ATR_JWT_TTL) -> str:
     now = datetime.datetime.now(tz=datetime.UTC)
     payload = {
@@ -62,7 +75,7 @@ def issue(uid: str, *, ttl: int = _ATR_JWT_TTL) -> str:
         "exp": now + datetime.timedelta(seconds=ttl),
         "jti": secrets.token_hex(128 // 8),
     }
-    return jwt.encode(payload, _JWT_SECRET_KEY, algorithm=_ALGORITHM)
+    return jwt.encode(payload, _signing_key(), algorithm=_ALGORITHM)
 
 
 def require[**P, R](func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, 
Awaitable[R]]:
@@ -90,14 +103,22 @@ def require[**P, R](func: Callable[P, Coroutine[Any, Any, 
R]]) -> Callable[P, Aw
     return wrapper
 
 
+def setup_signing_key(app: base.QuartApp) -> None:
+    key = _read_signing_key()
+    if key is None:
+        key = write_new_signing_key()
+    app.extensions[_JWT_KEY_APP_EXTENSION] = key
+
+
 async def verify(token: str) -> dict[str, Any]:
     # Grab the "supposed" asf UID from the token presented, to make sure we 
know who failed to authenticate on failure.
+    jwt_secret_key = _signing_key()
     claims_unsafe = jwt.decode(token, options={"verify_signature": False}, 
algorithms=[_ALGORITHM])
     asf_uid = claims_unsafe.get("sub")
     log.set_asf_uid(asf_uid)
     claims = jwt.decode(
         token,
-        _JWT_SECRET_KEY,
+        jwt_secret_key,
         algorithms=[_ALGORITHM],
         issuer=_ATR_JWT_ISSUER,
         audience=_ATR_JWT_AUDIENCE,
@@ -167,6 +188,12 @@ async def verify_github_oidc(token: str) -> dict[str, Any]:
     return github.TrustedPublisherPayload.model_validate(payload).model_dump()
 
 
+def write_new_signing_key() -> str:
+    key = _new_signing_key()
+    _write_signing_key(key)
+    return key
+
+
 def _extract_bearer_token(request: quart.Request) -> str:
     header = request.headers.get("Authorization", "")
     scheme, _, token = header.partition(" ")
@@ -175,3 +202,47 @@ def _extract_bearer_token(request: quart.Request) -> str:
             "Authentication required. Please provide a valid Bearer token in 
the Authorization header", errorcode=401
         )
     return token
+
+
+def _new_signing_key() -> str:
+    return secrets.token_hex(256 // 8)
+
+
+def _read_signing_key() -> str | None:
+    if not _JWT_KEY_PATH.exists():
+        return None
+    key = _JWT_KEY_PATH.read_text(encoding="utf-8").strip()
+    if key == "":
+        raise RuntimeError(f"JWT signing key file is empty: {_JWT_KEY_PATH}")
+    if len(key) != _JWT_KEY_HEX_LENGTH:
+        raise RuntimeError("JWT signing key is not 256 bits")
+    return key
+
+
+def _signing_key() -> str:
+    app = asfquart.APP
+    if app is not None:
+        key = app.extensions.get(_JWT_KEY_APP_EXTENSION)
+        if isinstance(key, str) and key:
+            return key
+    key = _read_signing_key()
+    if key is not None:
+        return key
+    raise RuntimeError("JWT signing key is not initialised")
+
+
+def _write_signing_key(key: str) -> None:
+    if key == "":
+        raise ValueError("JWT signing key must not be empty")
+    if len(key) != _JWT_KEY_HEX_LENGTH:
+        raise ValueError("JWT signing key must be 256 bits")
+    _JWT_KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
+    if _JWT_KEY_TMP_PATH.exists():
+        _JWT_KEY_TMP_PATH.unlink()
+    temp_fd = os.open(_JWT_KEY_TMP_PATH, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 
0o600)
+    with os.fdopen(temp_fd, "w", encoding="utf-8") as file:
+        file.write(key)
+        file.flush()
+        os.fsync(file.fileno())
+    os.chmod(_JWT_KEY_TMP_PATH, 0o400)
+    os.replace(_JWT_KEY_TMP_PATH, _JWT_KEY_PATH)
diff --git a/atr/server.py b/atr/server.py
index bcb45517..e77a86b7 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -53,6 +53,7 @@ import atr.config as config
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.filters as filters
+import atr.jwtoken as jwtoken
 import atr.log as log
 import atr.manager as manager
 import atr.models.sql as sql
@@ -583,6 +584,7 @@ def _create_app(app_config: type[config.AppConfig]) -> 
base.QuartApp:
     _validate_secrets_permissions(pathlib.Path(app_config.STATE_DIR))
     log.performance_init()
     app = _app_create_base(app_config)
+    jwtoken.setup_signing_key(app)
 
     _app_setup_api_docs(app)
     quart_wtf.CSRFProtect(app)
@@ -984,6 +986,14 @@ def _validate_config(app_config: type[config.AppConfig], 
hot_reload: bool) -> No
         print("!!!", file=sys.stderr)
         # sys.exit(1)
 
+    if (app_config.JWT_SECRET_KEY is not None) and (hot_reload is False):
+        print("!!!", file=sys.stderr)
+        print("WARNING: JWT_SECRET_KEY is no longer supported", 
file=sys.stderr)
+        print("Please remove JWT_SECRET_KEY from secrets and environment", 
file=sys.stderr)
+        print("ATR now uses secrets/generated/jwt_secret_key.txt", 
file=sys.stderr)
+        print("!!!", file=sys.stderr)
+        # sys.exit(1)
+
 
 def _validate_secrets_permissions(state_dir: pathlib.Path) -> None:
     secrets_dirs = [
diff --git a/atr/storage/writers/tokens.py b/atr/storage/writers/tokens.py
index 7ade0050..73d8e109 100644
--- a/atr/storage/writers/tokens.py
+++ b/atr/storage/writers/tokens.py
@@ -18,6 +18,7 @@
 # Removing this will cause circular imports
 from __future__ import annotations
 
+import asyncio
 import datetime
 import hashlib
 from typing import Final
@@ -212,3 +213,11 @@ class FoundationAdmin(FoundationCommitter):
                 tokens_revoked=count,
             )
         return count
+
+    async def rotate_jwt_signing_key(self) -> None:
+        key = await asyncio.to_thread(jwtoken.write_new_signing_key)
+        jwtoken.activate_signing_key(key)
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            action="rotate_jwt_signing_key",
+        )
diff --git a/atr/templates/includes/topnav.html 
b/atr/templates/includes/topnav.html
index 66300166..c021a261 100644
--- a/atr/templates/includes/topnav.html
+++ b/atr/templates/includes/topnav.html
@@ -220,11 +220,21 @@
                    href="{{ as_url(admin.revoke_user_tokens_get) }}"
                    {% if request.endpoint == 
'atr_admin_revoke_user_tokens_get' %}class="active"{% endif %}><i class="bi 
bi-shield-x"></i> Revoke user tokens</a>
               </li>
+              <li>
+                <a class="dropdown-item"
+                   href="{{ as_url(admin.rotate_jwt_key_get) }}"
+                   {% if request.endpoint == 'atr_admin_rotate_jwt_key_get' 
%}class="active"{% endif %}><i class="bi bi-arrow-clockwise"></i> Rotate JWT 
key</a>
+              </li>
               <li>
                 <a class="dropdown-item"
                    href="{{ as_url(admin.toggle_view_get) }}"
                    {% if request.endpoint == 'atr_admin_toggle_view_get' 
%}class="active"{% endif %}><i class="bi bi-person-badge"></i> Toggle admin 
view</a>
               </li>
+              <li>
+                <a class="dropdown-item"
+                   href="{{ as_url(admin.validate_jwt_get) }}"
+                   {% if request.endpoint == 'atr_admin_validate_jwt_get' 
%}class="active"{% endif %}><i class="bi bi-check-circle"></i> Validate JWT</a>
+              </li>
               <li>
                 <hr class="dropdown-divider" />
               </li>


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

Reply via email to