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]