From 687712c0e63df1414badd8b28d6519937ee60c52 Mon Sep 17 00:00:00 2001 From: mariatta Date: Mon, 26 Oct 2026 10:30:38 -0700 Subject: [PATCH 1/4] Read the session key from the SESSION_KEY environment variable The Fernet key that encrypts the session cookie was generated fresh on every start, so each deploy or restart signed everyone out. The key now comes from SESSION_KEY, set in the cabotage UI like the other secrets. When it is missing, the app still generates a random key, which keeps local development working, and logs a warning. --- blurb_it/__main__.py | 24 ++++++++++++++++++++---- readme.rst | 5 +++++ tests/test_main.py | 24 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/blurb_it/__main__.py b/blurb_it/__main__.py index 038ac08..b4875dc 100644 --- a/blurb_it/__main__.py +++ b/blurb_it/__main__.py @@ -2,6 +2,7 @@ import base64 import json +import logging import os import aiohttp @@ -308,14 +309,29 @@ async def handle_add_blurb_post(request: Request) -> Response: raise web.HTTPFound(location=request.app.router["add_blurb"].url_for()) -if __name__ == "__main__": # pragma: no cover - fernet_key = fernet.Fernet.generate_key() - secret_key = base64.urlsafe_b64decode(fernet_key) +def get_session_key() -> str: + """Return the Fernet key that encrypts the session cookie. + + The key comes from the ``SESSION_KEY`` environment variable so that it + stays the same across restarts and deploys. Without it a random key is + generated, which is fine for local development, but then every restart + invalidates all existing sessions and signs everyone out. + """ + session_key = os.environ.get("SESSION_KEY") + if session_key: + return session_key + logging.warning( + "SESSION_KEY is not set; using a random session key, " + "so sessions will not survive a restart." + ) + return fernet.Fernet.generate_key().decode() + +if __name__ == "__main__": # pragma: no cover app = web.Application( middlewares=[ middleware.error_middleware, - session_middleware(EncryptedCookieStorage(secret_key)), + session_middleware(EncryptedCookieStorage(get_session_key())), ] ) diff --git a/readme.rst b/readme.rst index ffe9741..d4d52d8 100644 --- a/readme.rst +++ b/readme.rst @@ -61,6 +61,11 @@ Configuration variables, set in the cabotage UI: - ``GH_APP_ID``, ``GH_PRIVATE_KEY``: the GitHub App ID and private key. - ``APP_URL``: the public hostname, without scheme. Must match the OAuth App callback URL. +- ``SESSION_KEY``: the Fernet key that encrypts the session cookie. Generate + it once with ``python -c "from cryptography.fernet import Fernet; + print(Fernet.generate_key().decode())"`` and keep it the same across + deploys; changing it signs everyone out. Without it the app generates a + random key at startup, so every restart signs everyone out. - ``SENTRY_DSN``: where errors are reported. diff --git a/tests/test_main.py b/tests/test_main.py index a481939..0ac1cc0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,6 +2,8 @@ import asyncio import base64 +import logging +import os import pathlib import aiohttp @@ -613,3 +615,25 @@ async def get_installation(gh, jwt, username): # Assert assert resp.status == 503 assert "GitHub is not responding right now" in await resp.text() + + +def test_get_session_key_from_env(monkeypatch, caplog) -> None: + monkeypatch.setenv("SESSION_KEY", fernet.Fernet.generate_key().decode()) + + session_key = __main__.get_session_key() + + assert session_key == os.environ["SESSION_KEY"] + assert "SESSION_KEY is not set" not in caplog.text + # The key must be usable as-is by the cookie storage. + EncryptedCookieStorage(session_key) + + +def test_get_session_key_random_when_unset(monkeypatch, caplog) -> None: + monkeypatch.delenv("SESSION_KEY", raising=False) + + with caplog.at_level(logging.WARNING): + session_key = __main__.get_session_key() + + assert session_key != __main__.get_session_key() + assert "SESSION_KEY is not set" in caplog.text + EncryptedCookieStorage(session_key) From ede2bd194e1fbb8dfad92f727ddec2b5e2a80e69 Mon Sep 17 00:00:00 2001 From: mariatta Date: Mon, 26 Oct 2026 10:33:39 -0700 Subject: [PATCH 2/4] Name SESSION_KEY in the error when its value is not a Fernet key A malformed value used to fail inside EncryptedCookieStorage with a message that did not say which variable was wrong. Also tightens the readme wording and drops an import the tests no longer need. --- blurb_it/__main__.py | 8 ++++++++ readme.rst | 2 +- tests/test_main.py | 13 ++++++++++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/blurb_it/__main__.py b/blurb_it/__main__.py index b4875dc..50e3ceb 100644 --- a/blurb_it/__main__.py +++ b/blurb_it/__main__.py @@ -319,6 +319,14 @@ def get_session_key() -> str: """ session_key = os.environ.get("SESSION_KEY") if session_key: + try: + fernet.Fernet(session_key) + except ValueError as exc: + raise ValueError( + "SESSION_KEY is not a valid Fernet key. Generate one with " + "`python -c \"from cryptography.fernet import Fernet; " + "print(Fernet.generate_key().decode())\"`." + ) from exc return session_key logging.warning( "SESSION_KEY is not set; using a random session key, " diff --git a/readme.rst b/readme.rst index d4d52d8..da36a84 100644 --- a/readme.rst +++ b/readme.rst @@ -65,7 +65,7 @@ Configuration variables, set in the cabotage UI: it once with ``python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`` and keep it the same across deploys; changing it signs everyone out. Without it the app generates a - random key at startup, so every restart signs everyone out. + random key at startup, with the same effect on every restart. - ``SENTRY_DSN``: where errors are reported. diff --git a/tests/test_main.py b/tests/test_main.py index 0ac1cc0..2db7248 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,7 +3,6 @@ import asyncio import base64 import logging -import os import pathlib import aiohttp @@ -618,11 +617,12 @@ async def get_installation(gh, jwt, username): def test_get_session_key_from_env(monkeypatch, caplog) -> None: - monkeypatch.setenv("SESSION_KEY", fernet.Fernet.generate_key().decode()) + expected = fernet.Fernet.generate_key().decode() + monkeypatch.setenv("SESSION_KEY", expected) session_key = __main__.get_session_key() - assert session_key == os.environ["SESSION_KEY"] + assert session_key == expected assert "SESSION_KEY is not set" not in caplog.text # The key must be usable as-is by the cookie storage. EncryptedCookieStorage(session_key) @@ -637,3 +637,10 @@ def test_get_session_key_random_when_unset(monkeypatch, caplog) -> None: assert session_key != __main__.get_session_key() assert "SESSION_KEY is not set" in caplog.text EncryptedCookieStorage(session_key) + + +def test_get_session_key_rejects_invalid_value(monkeypatch) -> None: + monkeypatch.setenv("SESSION_KEY", "not-a-fernet-key") + + with pytest.raises(ValueError, match="SESSION_KEY is not a valid Fernet key"): + __main__.get_session_key() From 2bd2a234c468c61a27154522b2eaf202dcbc878d Mon Sep 17 00:00:00 2001 From: mariatta Date: Mon, 26 Oct 2026 10:50:36 -0700 Subject: [PATCH 3/4] Point SESSION_KEY errors at the readme instead of embedding the command The generate command now lives in its own readme section under Deploy. The startup error and the missing-key warning just say what is wrong and refer to that section. --- blurb_it/__main__.py | 9 ++++----- readme.rst | 22 +++++++++++++++++----- tests/test_main.py | 2 +- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/blurb_it/__main__.py b/blurb_it/__main__.py index 50e3ceb..0576360 100644 --- a/blurb_it/__main__.py +++ b/blurb_it/__main__.py @@ -323,14 +323,13 @@ def get_session_key() -> str: fernet.Fernet(session_key) except ValueError as exc: raise ValueError( - "SESSION_KEY is not a valid Fernet key. Generate one with " - "`python -c \"from cryptography.fernet import Fernet; " - "print(Fernet.generate_key().decode())\"`." + "SESSION_KEY is not a valid session key. " + "See the Deploy section of the readme." ) from exc return session_key logging.warning( - "SESSION_KEY is not set; using a random session key, " - "so sessions will not survive a restart." + "SESSION_KEY is not set; using a random session key, so sessions " + "will not survive a restart. See the Deploy section of the readme." ) return fernet.Fernet.generate_key().decode() diff --git a/readme.rst b/readme.rst index da36a84..e0c739a 100644 --- a/readme.rst +++ b/readme.rst @@ -61,13 +61,25 @@ Configuration variables, set in the cabotage UI: - ``GH_APP_ID``, ``GH_PRIVATE_KEY``: the GitHub App ID and private key. - ``APP_URL``: the public hostname, without scheme. Must match the OAuth App callback URL. -- ``SESSION_KEY``: the Fernet key that encrypts the session cookie. Generate - it once with ``python -c "from cryptography.fernet import Fernet; - print(Fernet.generate_key().decode())"`` and keep it the same across - deploys; changing it signs everyone out. Without it the app generates a - random key at startup, with the same effect on every restart. +- ``SESSION_KEY``: the key that encrypts the session cookie. See + `Generating the session key`_ below. - ``SENTRY_DSN``: where errors are reported. +Generating the session key +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``SESSION_KEY`` is a Fernet key. Generate it once, in any environment that +has the ``cryptography`` package installed: + +.. code-block:: console + + $ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + +Copy the whole printed line into ``SESSION_KEY``. Keep it the same across +deploys: changing it signs everyone out. Without it the app generates a +random key at startup, with the same effect on every restart. If the value is +not a valid Fernet key, the app refuses to start. + Requirements and dependencies ============================= diff --git a/tests/test_main.py b/tests/test_main.py index 2db7248..d8fde4f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -642,5 +642,5 @@ def test_get_session_key_random_when_unset(monkeypatch, caplog) -> None: def test_get_session_key_rejects_invalid_value(monkeypatch) -> None: monkeypatch.setenv("SESSION_KEY", "not-a-fernet-key") - with pytest.raises(ValueError, match="SESSION_KEY is not a valid Fernet key"): + with pytest.raises(ValueError, match="SESSION_KEY is not a valid session key"): __main__.get_session_key() From 09d9542cf6b8a69b61c71e28205037465604af39 Mon Sep 17 00:00:00 2001 From: mariatta Date: Mon, 26 Oct 2026 10:53:06 -0700 Subject: [PATCH 4/4] Assert the readme pointer in the session key tests Without this the tests stayed green when the pointer was removed. --- tests/test_main.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index d8fde4f..00b074b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -636,11 +636,16 @@ def test_get_session_key_random_when_unset(monkeypatch, caplog) -> None: assert session_key != __main__.get_session_key() assert "SESSION_KEY is not set" in caplog.text + assert "See the Deploy section of the readme." in caplog.text EncryptedCookieStorage(session_key) def test_get_session_key_rejects_invalid_value(monkeypatch) -> None: monkeypatch.setenv("SESSION_KEY", "not-a-fernet-key") - with pytest.raises(ValueError, match="SESSION_KEY is not a valid session key"): + with pytest.raises( + ValueError, + match=r"SESSION_KEY is not a valid session key\. " + r"See the Deploy section of the readme\.", + ): __main__.get_session_key()