diff --git a/docs/changelog/next_release/275.feature.rst b/docs/changelog/next_release/275.feature.rst new file mode 100644 index 00000000..19587d08 --- /dev/null +++ b/docs/changelog/next_release/275.feature.rst @@ -0,0 +1 @@ +Implement ``GET /v1/auth/logout`` endpoint. diff --git a/poetry.lock b/poetry.lock index c1cd5566..b0f7a7c3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "accessible-pygments" @@ -589,7 +589,7 @@ version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" -groups = ["main", "test"] +groups = ["main", "dev", "test"] files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -659,7 +659,7 @@ files = [ {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, ] -markers = {main = "(extra == \"worker\" or extra == \"server\") and platform_python_implementation != \"PyPy\" or extra == \"worker\"", test = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""} +markers = {main = "(extra == \"worker\" or extra == \"server\") and platform_python_implementation != \"PyPy\" or extra == \"worker\"", dev = "platform_python_implementation != \"PyPy\"", test = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""} [package.dependencies] pycparser = "*" @@ -1003,10 +1003,9 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] name = "cryptography" version = "45.0.5" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = true +optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.7" -groups = ["main"] -markers = "extra == \"worker\" or extra == \"server\"" +groups = ["main", "dev"] files = [ {file = "cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8"}, {file = "cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d"}, @@ -1046,6 +1045,7 @@ files = [ {file = "cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f"}, {file = "cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a"}, ] +markers = {main = "extra == \"worker\" or extra == \"server\""} [package.dependencies] cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} @@ -2542,12 +2542,12 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" -groups = ["main", "test"] +groups = ["main", "dev", "test"] files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] -markers = {main = "(extra == \"worker\" or extra == \"server\") and platform_python_implementation != \"PyPy\" or extra == \"worker\"", test = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""} +markers = {main = "(extra == \"worker\" or extra == \"server\") and platform_python_implementation != \"PyPy\" or extra == \"worker\"", dev = "platform_python_implementation != \"PyPy\"", test = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""} [[package]] name = "pycryptodome" @@ -3917,6 +3917,21 @@ notebook = ["ipywidgets (>=6)"] slack = ["slack-sdk"] telegram = ["requests"] +[[package]] +name = "types-jwcrypto" +version = "1.5.0.20250516" +description = "Typing stubs for jwcrypto" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "types_jwcrypto-1.5.0.20250516-py3-none-any.whl", hash = "sha256:0fdeac3412bb2737f233d1176487a506d225fdd026e3a8e0208d123313c3c7cd"}, + {file = "types_jwcrypto-1.5.0.20250516.tar.gz", hash = "sha256:7ca1878c6fed2bb7a046cf832b28d3d5340deb84f1bf5b3831d09257c7f1d030"}, +] + +[package.dependencies] +cryptography = "*" + [[package]] name = "typing-extensions" version = "4.14.1" @@ -4196,4 +4211,4 @@ worker = ["asgi-correlation-id", "celery", "coloredlogs", "horizon-hwm-store", " [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "80d81f8b2a1066f4320f51769038d8604c329ecae63af5245721d915e2afb469" +content-hash = "713ab8e45e6206d9911aada531c0c7cf61a5d7575a8a524886a4fdbd428661c1" diff --git a/pyproject.toml b/pyproject.toml index 06b2bff9..280fbf84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -145,6 +145,7 @@ black = "^25.1.0" flake8 = "^7.2.0" flake8-pyproject = "^1.2.3" sqlalchemy = {extras = ["mypy"], version = "^2.0.40"} +types-jwcrypto = "^1.5.0" [tool.poetry.group.docs.dependencies] autodoc-pydantic = "^2.2.0" diff --git a/syncmaster/exceptions/auth.py b/syncmaster/exceptions/auth.py index ec2dc00f..0e7aa0fd 100644 --- a/syncmaster/exceptions/auth.py +++ b/syncmaster/exceptions/auth.py @@ -28,3 +28,19 @@ def message(self) -> str: @property def details(self) -> Any: return self._details + + +class LogoutError(SyncmasterError): + """Error on logout request""" + + def __init__(self, details: str) -> None: + self._message = "Logout error" + self._details = details + + @property + def message(self) -> str: + return self._message + + @property + def details(self) -> Any: + return self._details diff --git a/syncmaster/server/api/v1/auth.py b/syncmaster/server/api/v1/auth.py index 9f3f1b98..c823c9f6 100644 --- a/syncmaster/server/api/v1/auth.py +++ b/syncmaster/server/api/v1/auth.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, Request, Response from fastapi.security import OAuth2PasswordRequestForm +from syncmaster.db.models import User from syncmaster.errors.registration import get_error_responses from syncmaster.errors.schemas.invalid_request import InvalidRequestSchema from syncmaster.errors.schemas.not_authorized import NotAuthorizedSchema @@ -16,6 +17,7 @@ DummyAuthProvider, KeycloakAuthProvider, ) +from syncmaster.server.services.get_user import get_user router = APIRouter( prefix="/auth", @@ -48,9 +50,23 @@ async def auth_callback( ): token = await auth_provider.get_token_authorization_code_grant( code=code, - redirect_uri=auth_provider.settings.keycloak.redirect_uri, ) request.session["access_token"] = token["access_token"] request.session["refresh_token"] = token["refresh_token"] + return Response(status_code=NO_CONTENT) + +@router.get( + "/logout", + summary="Logout user", + status_code=NO_CONTENT, +) +async def logout( + request: Request, + current_user: Annotated[User, Depends(get_user(is_active=True))], + auth_provider: Annotated[KeycloakAuthProvider, Depends(Stub(AuthProvider))], +): + refresh_token = request.session.get("refresh_token", None) + request.session.clear() + await auth_provider.logout(user=current_user, refresh_token=refresh_token) return Response(status_code=NO_CONTENT) diff --git a/syncmaster/server/providers/auth/base_provider.py b/syncmaster/server/providers/auth/base_provider.py index 1f5c10d8..becfd586 100644 --- a/syncmaster/server/providers/auth/base_provider.py +++ b/syncmaster/server/providers/auth/base_provider.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from typing import Any -from fastapi import FastAPI +from fastapi import FastAPI, Request from syncmaster.db.models import User @@ -52,7 +52,7 @@ def __init__( ... @abstractmethod - async def get_current_user(self, access_token: Any, *args, **kwargs) -> User: + async def get_current_user(self, access_token: Any, request: Request) -> User: """ This method should return currently logged in user. @@ -104,7 +104,6 @@ async def get_token_password_grant( async def get_token_authorization_code_grant( self, code: str, - redirect_uri: str, scopes: list[str] | None = None, client_id: str | None = None, client_secret: str | None = None, @@ -112,3 +111,9 @@ async def get_token_authorization_code_grant( """ Obtain a token using the Authorization Code grant. """ + + @abstractmethod + async def logout(self, user: User, refresh_token: str | None) -> None: + """ + Logout user + """ diff --git a/syncmaster/server/providers/auth/dummy_provider.py b/syncmaster/server/providers/auth/dummy_provider.py index 94f64e53..044e1251 100644 --- a/syncmaster/server/providers/auth/dummy_provider.py +++ b/syncmaster/server/providers/auth/dummy_provider.py @@ -20,7 +20,7 @@ log = logging.getLogger(__name__) -class DummyAuthProvider(AuthProvider): +class DummyAuthProvider(AuthProvider): # noqa: WPS338 def __init__( self, settings: Annotated[DummyAuthProviderSettings, Depends(Stub(DummyAuthProviderSettings))], @@ -76,16 +76,6 @@ async def get_token_password_grant( "expires_at": expires_at, } - async def get_token_authorization_code_grant( - self, - code: str, - redirect_uri: str, - scopes: list[str] | None = None, - client_id: str | None = None, - client_secret: str | None = None, - ) -> dict[str, Any]: - raise NotImplementedError("Authorization code grant is not supported by DummyAuthProvider.") - def _generate_access_token(self, user_id: int) -> tuple[str, float]: expires_at = time() + self._settings.access_token.expire_seconds payload = { @@ -109,3 +99,15 @@ def _get_user_id_from_token(self, token: str) -> int: return int(payload["user_id"]) except (KeyError, TypeError, ValueError) as e: raise AuthorizationError("Invalid token") from e + + async def get_token_authorization_code_grant( + self, + code: str, + scopes: list[str] | None = None, + client_id: str | None = None, + client_secret: str | None = None, + ) -> dict[str, Any]: + raise NotImplementedError("Authorization code grant is not supported by DummyAuthProvider") + + async def logout(self, user: User, refresh_token: str | None) -> None: + raise NotImplementedError("Logout is not supported by DummyAuthProvider") diff --git a/syncmaster/server/providers/auth/keycloak_provider.py b/syncmaster/server/providers/auth/keycloak_provider.py index ab31bedb..39281864 100644 --- a/syncmaster/server/providers/auth/keycloak_provider.py +++ b/syncmaster/server/providers/auth/keycloak_provider.py @@ -4,10 +4,12 @@ from typing import Annotated, Any from fastapi import Depends, FastAPI, Request -from keycloak import KeycloakOpenID +from jwcrypto.common import JWException +from keycloak import KeycloakOpenID, KeycloakOperationError +from syncmaster.db.models.user import User from syncmaster.exceptions import EntityNotFoundError -from syncmaster.exceptions.auth import AuthorizationError +from syncmaster.exceptions.auth import AuthorizationError, LogoutError from syncmaster.exceptions.redirect import RedirectException from syncmaster.server.dependencies import Stub from syncmaster.server.providers.auth.base_provider import AuthProvider @@ -55,35 +57,31 @@ async def get_token_password_grant( async def get_token_authorization_code_grant( self, code: str, - redirect_uri: str, scopes: list[str] | None = None, client_id: str | None = None, client_secret: str | None = None, ) -> dict[str, Any]: try: - redirect_uri = redirect_uri or self.settings.keycloak.redirect_uri token = self.keycloak_openid.token( grant_type="authorization_code", code=code, - redirect_uri=redirect_uri, + redirect_uri=self.settings.keycloak.redirect_uri, ) return token - except Exception as e: + except KeycloakOperationError as e: raise AuthorizationError("Failed to get token") from e - async def get_current_user(self, access_token: str, *args, **kwargs) -> Any: # noqa: WPS231 - request: Request = kwargs["request"] - refresh_token = request.session.get("refresh_token") - + async def get_current_user(self, access_token: str, request: Request) -> User: # noqa: WPS231 if not access_token: log.debug("No access token found in session.") - self.redirect_to_auth(request.url.path) + self.redirect_to_auth() + refresh_token = request.session.get("refresh_token") try: # if user is disabled or blocked in Keycloak after the token is issued, he will # remain authorized until the token expires (not more than 15 minutes in MTS SSO) token_info = self.keycloak_openid.decode_token(token=access_token) - except Exception as e: + except (KeycloakOperationError, JWException) as e: log.info("Access token is invalid or expired: %s", e) token_info = None @@ -91,7 +89,7 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> Any: # log.debug("Access token invalid. Attempting to refresh.") try: - new_tokens = await self.refresh_access_token(refresh_token) + new_tokens = self.keycloak_openid.refresh_token(refresh_token) new_access_token = new_tokens["access_token"] new_refresh_token = new_tokens["refresh_token"] @@ -102,9 +100,9 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> Any: # token=new_access_token, ) log.debug("Access token refreshed and decoded successfully.") - except Exception as e: + except (KeycloakOperationError, JWException) as e: log.debug("Failed to refresh access token: %s", e) - self.redirect_to_auth(request.url.path) + self.redirect_to_auth() if not token_info: raise AuthorizationError("Invalid token payload") @@ -131,13 +129,21 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> Any: # ) return user - async def refresh_access_token(self, refresh_token: str) -> dict[str, Any]: - new_tokens = self.keycloak_openid.refresh_token(refresh_token) - return new_tokens - - def redirect_to_auth(self, path: str) -> None: + def redirect_to_auth(self) -> None: auth_url = self.keycloak_openid.auth_url( redirect_uri=self.settings.keycloak.redirect_uri, scope=self.settings.keycloak.scope, ) raise RedirectException(redirect_url=auth_url) + + async def logout(self, user: User, refresh_token: str | None) -> None: + if not refresh_token: + log.debug("No refresh token found in session.") + return + + try: + self.keycloak_openid.logout(refresh_token) + except KeycloakOperationError as err: + msg = f"Can't logout user: {user.username}" + log.debug("%s. Error: %s", msg, err) + raise LogoutError(msg) from err diff --git a/tests/test_unit/test_auth/auth_fixtures/__init__.py b/tests/test_unit/test_auth/auth_fixtures/__init__.py index 78437abd..ca1d1a65 100644 --- a/tests/test_unit/test_auth/auth_fixtures/__init__.py +++ b/tests/test_unit/test_auth/auth_fixtures/__init__.py @@ -1,7 +1,17 @@ from tests.test_unit.test_auth.auth_fixtures.keycloak_fixture import ( create_session_cookie, + mock_keycloak_logout, mock_keycloak_realm, mock_keycloak_token_refresh, mock_keycloak_well_known, rsa_keys, ) + +__all__ = [ + "create_session_cookie", + "mock_keycloak_logout", + "mock_keycloak_realm", + "mock_keycloak_token_refresh", + "mock_keycloak_well_known", + "rsa_keys", +] diff --git a/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py b/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py index 61637a42..256de00f 100644 --- a/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py +++ b/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py @@ -5,9 +5,13 @@ import jwt import pytest import responses -from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + PublicFormat, +) from itsdangerous import TimestampSigner @@ -19,9 +23,9 @@ def rsa_keys(): key_size=2048, ) private_pem = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), + encoding=Encoding.PEM, + format=PrivateFormat.PKCS8, + encryption_algorithm=NoEncryption(), ) public_key = private_key.public_key() @@ -84,18 +88,20 @@ def mock_keycloak_well_known(settings): keycloak_settings = settings.auth.model_dump()["keycloak"] server_url = keycloak_settings["server_url"] realm_name = keycloak_settings["client_id"] - well_known_url = f"{server_url}/realms/{realm_name}/.well-known/openid-configuration" + realm_url = f"{server_url}/realms/{realm_name}" + well_known_url = f"{realm_url}/.well-known/openid-configuration" + openid_url = f"{realm_url}/protocol/openid-connect" responses.add( responses.GET, well_known_url, json={ - "authorization_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/auth", - "token_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/token", - "userinfo_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/userinfo", - "end_session_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/logout", - "jwks_uri": f"{server_url}/realms/{realm_name}/protocol/openid-connect/certs", - "issuer": f"{server_url}/realms/{realm_name}", + "authorization_endpoint": f"{openid_url}/auth", + "token_endpoint": f"{openid_url}/token", + "userinfo_endpoint": f"{openid_url}/userinfo", + "end_session_endpoint": f"{openid_url}/logout", + "jwks_uri": f"{openid_url}/certs", + "issuer": realm_url, }, status=200, content_type="application/json", @@ -116,8 +122,8 @@ def mock_keycloak_realm(settings, rsa_keys): json={ "realm": realm_name, "public_key": public_pem_str, - "token-service": f"{server_url}/realms/{realm_name}/protocol/openid-connect/token", - "account-service": f"{server_url}/realms/{realm_name}/account", + "token-service": f"{realm_url}/protocol/openid-connect/token", + "account-service": f"{realm_url}/account", }, status=200, content_type="application/json", @@ -129,7 +135,8 @@ def mock_keycloak_token_refresh(settings, rsa_keys): keycloak_settings = settings.auth.model_dump()["keycloak"] server_url = keycloak_settings["server_url"] realm_name = keycloak_settings["client_id"] - token_url = f"{server_url}/realms/{realm_name}/protocol/openid-connect/token" + realm_url = f"{server_url}/realms/{realm_name}" + token_url = f"{realm_url}/protocol/openid-connect/token" # generate new access and refresh tokens expires_in = int(time.time()) + 1000 @@ -159,3 +166,18 @@ def mock_keycloak_token_refresh(settings, rsa_keys): status=200, content_type="application/json", ) + + +@pytest.fixture +def mock_keycloak_logout(settings): + keycloak_settings = settings.auth.model_dump()["keycloak"] + server_url = keycloak_settings["server_url"] + realm_name = keycloak_settings["client_id"] + realm_url = f"{server_url}/realms/{realm_name}" + logout_url = f"{realm_url}/protocol/openid-connect/logout" + + responses.add( + responses.POST, + logout_url, + status=204, + ) diff --git a/tests/test_unit/test_auth/test_auth_keycloak.py b/tests/test_unit/test_auth/test_auth_keycloak.py index 1cf55470..5acaecb4 100644 --- a/tests/test_unit/test_auth/test_auth_keycloak.py +++ b/tests/test_unit/test_auth/test_auth_keycloak.py @@ -24,8 +24,14 @@ ], indirect=True, ) -async def test_keycloak_get_user_unauthorized(client: AsyncClient, mock_keycloak_well_known): - response = await client.get("/v1/users/some_user_id") +async def test_keycloak_get_user_unauthorized( + client: AsyncClient, + simple_user: MockUser, + mock_keycloak_well_known, + mock_keycloak_realm, +): + client.cookies.clear() + response = await client.get(f"/v1/users/{simple_user.id}") # redirect unauthorized user to Keycloak assert response.status_code == 401, response.text @@ -59,17 +65,15 @@ async def test_keycloak_get_user_authorized( mock_keycloak_well_known, mock_keycloak_realm, ): + client.cookies.clear() session_cookie = create_session_cookie(simple_user) - headers = { - "Cookie": f"session={session_cookie}", - } response = await client.get( f"/v1/users/{simple_user.id}", - headers=headers, + cookies={"session": session_cookie}, ) assert response.cookies.get("session") == session_cookie - assert response.status_code == 200, response.json() + assert response.status_code == 200, response.text assert response.json() == { "id": simple_user.id, "is_superuser": simple_user.is_superuser, @@ -100,21 +104,15 @@ async def test_keycloak_get_user_expired_access_token( mock_keycloak_token_refresh, ): session_cookie = create_session_cookie(simple_user, expire_in_msec=-100000000) # expired access token - headers = { - "Cookie": f"session={session_cookie}", - } - + client.cookies = {"session": session_cookie} with caplog.at_level(logging.DEBUG): - response = await client.get( - f"/v1/users/{simple_user.id}", - headers=headers, - ) + response = await client.get(f"/v1/users/{simple_user.id}") assert "Access token is invalid or expired" in caplog.text assert "Access token refreshed and decoded successfully" in caplog.text assert response.cookies.get("session") != session_cookie # cookie is updated - assert response.status_code == 200, response.json() + assert response.status_code == 200, response.text assert response.json() == { "id": simple_user.id, "is_superuser": simple_user.is_superuser, @@ -143,16 +141,9 @@ async def test_keycloak_get_user_inactive( mock_keycloak_well_known, mock_keycloak_realm, ): - session_cookie = create_session_cookie(inactive_user) - headers = { - "Cookie": f"session={session_cookie}", - } - - response = await client.get( - f"/v1/users/{simple_user.id}", - headers=headers, - ) - assert response.status_code == 403, response.json() + client.cookies = {"session": create_session_cookie(inactive_user)} + response = await client.get(f"/v1/users/{simple_user.id}") + assert response.status_code == 403, response.text assert response.json() == { "error": { "code": "forbidden", @@ -182,6 +173,7 @@ async def test_keycloak_auth_callback( mock_keycloak_token_refresh, caplog, ): + client.cookies.clear() with caplog.at_level(logging.DEBUG): response = await client.get( "/v1/auth/callback", @@ -189,4 +181,32 @@ async def test_keycloak_auth_callback( ) assert response.cookies.get("session"), caplog.text # cookie is set - assert response.status_code == 204, response.json() + assert response.status_code == 204, response.text + + +@responses.activate +@pytest.mark.parametrize( + "settings", + [ + { + "auth": { + "provider": KEYCLOAK_PROVIDER, + }, + }, + ], + indirect=True, +) +async def test_keycloak_auth_logout( + simple_user: MockUser, + client: AsyncClient, + settings: Settings, + create_session_cookie, + mock_keycloak_well_known, + mock_keycloak_realm, + mock_keycloak_token_refresh, + mock_keycloak_logout, +): + client.cookies = {"session": create_session_cookie(simple_user)} + response = await client.get("/v1/auth/logout") + assert response.status_code == 204, response.text + assert response.cookies.get("session") is None