From 11c8d80d59145ac26025073ade7dd31e98ded162 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:19:44 +0000 Subject: [PATCH 1/2] Initial plan From a4aaebfe6649ef051790b6af24e0739bdd9ecfed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:25:27 +0000 Subject: [PATCH 2/2] fix(auth): exempt /api/qr-auth/claim from CSRF to fix mobile QR login Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/middleware/csrf.py | 7 +++++++ tests/test_csrf.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index 842958af..f21088ad 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -20,6 +20,9 @@ How it works: Exempt paths (CSRF is not checked even for state-changing methods): - ``/oauth-callback`` – OAuth 2.0 callback; protected by the ``state`` parameter. +- ``/api/qr-auth/claim`` – Called by the unauthenticated mobile app; the + cryptographically-random, single-use challenge token provides equivalent + protection. """ import logging @@ -39,6 +42,10 @@ CSRF_PROTECTED_METHODS = {"POST", "PUT", "DELETE", "PATCH"} # their own replay-protection mechanism). CSRF_EXEMPT_PATHS = { "/oauth-callback", + # The mobile app calls this endpoint without a browser session/CSRF token. + # The cryptographically-random, single-use challenge token already provides + # equivalent protection against cross-site request forgery. + "/api/qr-auth/claim", } diff --git a/tests/test_csrf.py b/tests/test_csrf.py index a6041038..a80e6e8a 100644 --- a/tests/test_csrf.py +++ b/tests/test_csrf.py @@ -337,6 +337,27 @@ class TestCSRFMiddlewareDispatch: call_next.assert_called_once_with(request) + @pytest.mark.asyncio + async def test_qr_auth_claim_is_exempt(self): + """QR auth claim path is exempt from CSRF validation. + + The mobile app calls this endpoint without a browser session and + therefore without a CSRF token. The cryptographically-random, + single-use challenge token provides equivalent protection. + """ + middleware = self._make_middleware() + request = self._make_request( + method="POST", + path="/api/qr-auth/claim", + session={}, + ) + call_next = AsyncMock(return_value=MagicMock()) + + with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=None)): + result = await middleware.dispatch(request, call_next) + + call_next.assert_called_once_with(request) + # --------------------------------------------------------------------------- # Integration tests – via TestClient @@ -362,6 +383,7 @@ class TestCSRFIntegration: assert "PATCH" in CSRF_PROTECTED_METHODS assert "GET" not in CSRF_PROTECTED_METHODS assert "/oauth-callback" in CSRF_EXEMPT_PATHS + assert "/api/qr-auth/claim" in CSRF_EXEMPT_PATHS def test_csrf_middleware_noop_when_auth_disabled(self): """When AUTH_ENABLED=False the middleware dispatch is a no-op (no validation)."""