diff --git a/blurb_it/__main__.py b/blurb_it/__main__.py index 038ac08..0576360 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,36 @@ 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: + try: + fernet.Fernet(session_key) + except ValueError as exc: + raise ValueError( + "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. See the Deploy section of the readme." + ) + 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..e0c739a 100644 --- a/readme.rst +++ b/readme.rst @@ -61,8 +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 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 a481939..00b074b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,6 +2,7 @@ import asyncio import base64 +import logging import pathlib import aiohttp @@ -613,3 +614,38 @@ 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: + expected = fernet.Fernet.generate_key().decode() + monkeypatch.setenv("SESSION_KEY", expected) + + session_key = __main__.get_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) + + +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 + 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=r"SESSION_KEY is not a valid session key\. " + r"See the Deploy section of the readme\.", + ): + __main__.get_session_key()