diff --git a/backend/src/infrastructure/auth/routes.py b/backend/src/infrastructure/auth/routes.py index 86975207..9731d9a9 100644 --- a/backend/src/infrastructure/auth/routes.py +++ b/backend/src/infrastructure/auth/routes.py @@ -277,7 +277,13 @@ async def oauth_google_callback( } redirect_to = str(state_data.redirect_to) if state_data.redirect_to else "/" - return RedirectResponse(url=redirect_to, status_code=status.HTTP_302_FOUND) + # The cookies must ride the returned response itself: FastAPI does not + # merge headers set on the injected ``response`` into a directly-returned + # Response, so relying on it silently drops the session on the browser + # (redirect) flow - only the json flow would get the cookies. + redirect = RedirectResponse(url=redirect_to, status_code=status.HTTP_302_FOUND) + crud_auth.sessions.set_session_cookies(redirect, session_id, csrf_token) + return redirect except Exception as e: logger.error(f"Error in Google OAuth callback: {str(e)}", exc_info=True) diff --git a/backend/tests/integration/auth/test_endpoints.py b/backend/tests/integration/auth/test_endpoints.py index f423047c..dbe25fc8 100644 --- a/backend/tests/integration/auth/test_endpoints.py +++ b/backend/tests/integration/auth/test_endpoints.py @@ -325,6 +325,54 @@ async def test_oauth_callback_success_creates_user(client: AsyncClient): mock_storage.delete.assert_awaited_once() +@pytest.mark.asyncio +async def test_oauth_callback_redirect_sets_session_cookies(client: AsyncClient): + """The browser (redirect) callback flow must carry the session cookies on the 302. + + FastAPI does not merge headers set on the injected ``response`` into a + directly-returned ``RedirectResponse``, so the cookies must be set on the + redirect itself. The json flow is covered by + ``test_oauth_callback_success_creates_user``; this pins the redirect flow. + """ + valid_state = OAuthState( + state="redirect-state", + provider="google", + redirect_to="/docs", + code_verifier="test-code-verifier", + ) + mock_storage = MagicMock() + mock_storage.get = AsyncMock(return_value=valid_state) + mock_storage.delete = AsyncMock(return_value=None) + + mock_provider = MagicMock() + mock_provider.exchange_code = AsyncMock(return_value={"access_token": "tok"}) + mock_provider.get_user_info = AsyncMock(return_value={}) + mock_provider.process_user_info = AsyncMock( + return_value=OAuthUserInfo( + provider="google", + provider_user_id="google-uid-redirect", + email="redirect_flow@example.com", + email_verified=True, + name="Redirect Flow", + ) + ) + + with ( + patch(f"{ROUTES}.oauth_state_storage", mock_storage), + patch(f"{ROUTES}.oauth_providers", {"google": mock_provider}), + ): + response = await client.get( + "/api/v1/auth/oauth/callback/google", + params={"code": "test-code", "state": "redirect-state"}, + ) + + assert response.status_code == 302 + assert response.headers["location"] == "/docs" + set_cookie = response.headers.get_list("set-cookie") + assert any(c.startswith("session_id=") for c in set_cookie), set_cookie + assert any(c.startswith("csrf_token=") for c in set_cookie), set_cookie + + @pytest.mark.asyncio async def test_check_auth_user_not_found(client: AsyncClient): """A resolved principal whose user row is missing reports authenticated=false."""