You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/04/22 12:48:04 UTC

(tooling-trusted-releases) branch main updated: #1028 - terminate live sessions when tokens or keys are revoked

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

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


The following commit(s) were added to refs/heads/main by this push:
     new 318a1663 #1028 - terminate live sessions when tokens or keys are revoked
318a1663 is described below

commit 318a1663fad8c95a0bcf7e897f7166d8a57a1324
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Wed Apr 22 13:42:08 2026 +0100

    #1028 - terminate live sessions when tokens or keys are revoked
---
 atr/get/keys.py             |  8 ++++++++
 atr/get/tokens.py           |  7 +++++++
 atr/post/keys.py            |  6 +++++-
 atr/post/tokens.py          |  6 ++++--
 atr/sessions.py             |  9 +++++++++
 tests/unit/test_sessions.py | 38 ++++++++++++++++++++++++++++++++++++++
 6 files changed, 71 insertions(+), 3 deletions(-)

diff --git a/atr/get/keys.py b/atr/get/keys.py
index 0b8506e8..a12e98d4 100644
--- a/atr/get/keys.py
+++ b/atr/get/keys.py
@@ -426,6 +426,13 @@ def _render_committee_checkboxes(
 async def _ssh_keys(page: htm.Block, user_ssh_keys: list[sql.SSHKey]) -> None:
     page.h3["Your SSH keys"]
     if user_ssh_keys:
+        page.div(".alert.alert-warning.mb-3")[
+            htm.p(".fw-semibold.mb-1")["Note:"],
+            htm.p(".mb-0")[
+                "Deleting any SSH key will log you out of every ATR session for security. ",
+                "You will need to sign in again.",
+            ],
+        ]
         grid = htm.Block(htm.div, classes=".d-grid.gap-4")
         for key in user_ssh_keys:
             card_block = htm.Block(htm.div, classes=f"#ssh-key-{key.fingerprint}.card.p-3.border")
@@ -454,6 +461,7 @@ async def _ssh_keys(page: htm.Block, user_ssh_keys: list[sql.SSHKey]) -> None:
                 form_classes=".mt-3",
                 submit_label="Delete key",
                 submit_classes="btn btn-danger",
+                confirm="Deleting this SSH key will log you out of every session. Continue?",
                 defaults={"fingerprint": key.fingerprint},
                 empty=True,
             )
diff --git a/atr/get/tokens.py b/atr/get/tokens.py
index b643750f..441d6a84 100644
--- a/atr/get/tokens.py
+++ b/atr/get/tokens.py
@@ -46,6 +46,12 @@ async def tokens(_session: web.Committer, _tokens: Literal["tokens"]) -> str:
         token is shown only once when you create it. You can revoke tokens
         you no longer need."""
     ]
+    page.div(".alert.alert-warning.mb-3")[
+        htm.p(".fw-semibold.mb-1")["Note:"],
+        htm.p(".mb-0")[
+            "Revoking any token will log you out of every ATR session for security. You will need to sign in again."
+        ],
+    ]
     add_form = await form.render(
         model_cls=shared.tokens.AddTokenForm,
         form_classes=".mb-0",
@@ -113,6 +119,7 @@ async def _build_tokens_table(page: htm.Block, tokens_list: list[types.PersonalA
             form_classes=".mb-0",
             submit_classes="btn-sm btn-danger",
             submit_label="Revoke",
+            confirm="Revoking this token will log you out of every session. Continue?",
             defaults={"token_id": t.id},
             empty=True,
         )
diff --git a/atr/post/keys.py b/atr/post/keys.py
index 5da239db..cba5ba9a 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -32,6 +32,7 @@ import atr.log as log
 import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.models.unsafe as unsafe
+import atr.sessions as sessions
 import atr.shared as shared
 import atr.storage as storage
 import atr.storage.outcome as outcome
@@ -248,7 +249,10 @@ async def _delete_ssh_key(session: web.Committer, delete_form: shared.keys.Delet
         except storage.AccessError as e:
             return await session.redirect(get.keys.keys, error=f"Error deleting SSH key: {e}")
 
-    return await session.redirect(get.keys.keys, success="SSH key deleted successfully")
+    # audit_guidance revoking an SSH key terminates all of this user's OAuth sessions, in case the key was uploaded
+    # by an attacker-controlled session
+    await sessions.terminate_current_users_sessions(session.uid)
+    return await session.redirect(get.keys.keys)
 
 
 async def _fetch_keys_from_url(keys_url: str) -> str:
diff --git a/atr/post/tokens.py b/atr/post/tokens.py
index e2e88317..28a09807 100644
--- a/atr/post/tokens.py
+++ b/atr/post/tokens.py
@@ -20,12 +20,12 @@ import hashlib
 import secrets
 from typing import Final, Literal
 
-import quart
 import quart_rate_limiter as rate_limiter
 
 import atr.blueprints.post as post
 import atr.get as get
 import atr.htm as htm
+import atr.sessions as sessions
 import atr.shared as shared
 import atr.storage as storage
 import atr.web as web
@@ -96,5 +96,7 @@ async def _delete_token(session: web.Committer, delete_form: shared.tokens.Delet
     async with storage.write(session) as write:
         wafc = write.as_foundation_committer()
         await wafc.tokens.delete_token(delete_form.token_id)
-    await quart.flash("Token deleted successfully", "success")
+    # audit_guidance revoking a PAT terminates all of this user's OAuth sessions, in case the token was created by
+    # an attacker-controlled session
+    await sessions.terminate_current_users_sessions(session.uid)
     return await session.redirect(get.tokens.tokens)
diff --git a/atr/sessions.py b/atr/sessions.py
index ebf5fe91..fd1b8981 100644
--- a/atr/sessions.py
+++ b/atr/sessions.py
@@ -100,6 +100,15 @@ async def read() -> sql.UserSession | None:
     return None
 
 
+async def terminate_current_users_sessions(uid: str) -> int:
+    # revoking a credential must log the user out of every OAuth session,
+    # in case the revoked credential was created by an attacker-controlled session
+    count = await asfquart.APP.sessions.revoke_by_uid(uid)
+    await asfquart.session.aclear()
+    invalidate_cache()
+    return count
+
+
 def _prepare_session_data(session_data: dict[str, Any]) -> dict[str, Any]:
     # Field names match the raw OAuth dict from asfquart
     # They do not match ClientSession attribute names
diff --git a/tests/unit/test_sessions.py b/tests/unit/test_sessions.py
index 66f0cdf3..344fc12a 100644
--- a/tests/unit/test_sessions.py
+++ b/tests/unit/test_sessions.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import types
+import unittest.mock as mock
 
 import pytest
 import sqlalchemy.ext.asyncio
@@ -219,6 +220,43 @@ async def test_validate_updates_uts(store):
     assert second.uts >= first_uts
 
 
+@pytest.mark.asyncio
+async def test_terminate_current_users_sessions_revokes_by_uid_clears_cookie_and_cache():
+    mock_store = mock.MagicMock()
+    mock_store.revoke_by_uid = mock.AsyncMock(return_value=3)
+    mock_app = mock.MagicMock()
+    mock_app.sessions = mock_store
+
+    with (
+        mock.patch.object(sessions.asfquart, "APP", mock_app),
+        mock.patch.object(sessions.asfquart.session, "aclear", new=mock.AsyncMock()) as mock_aclear,
+        mock.patch.object(sessions, "invalidate_cache") as mock_invalidate,
+    ):
+        count = await sessions.terminate_current_users_sessions("alice")
+
+    assert count == 3
+    mock_store.revoke_by_uid.assert_awaited_once_with("alice")
+    mock_aclear.assert_awaited_once_with()
+    mock_invalidate.assert_called_once_with()
+
+
+@pytest.mark.asyncio
+async def test_terminate_current_users_sessions_returns_zero_when_no_sessions():
+    mock_store = mock.MagicMock()
+    mock_store.revoke_by_uid = mock.AsyncMock(return_value=0)
+    mock_app = mock.MagicMock()
+    mock_app.sessions = mock_store
+
+    with (
+        mock.patch.object(sessions.asfquart, "APP", mock_app),
+        mock.patch.object(sessions.asfquart.session, "aclear", new=mock.AsyncMock()),
+        mock.patch.object(sessions, "invalidate_cache"),
+    ):
+        count = await sessions.terminate_current_users_sessions("bob")
+
+    assert count == 0
+
+
 def _hsid() -> str:
     global _COUNTER
     _COUNTER += 1


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org