diff --git a/backend/src/apiserver/app.py b/backend/src/apiserver/app.py index 23863b5..4fe9d0f 100644 --- a/backend/src/apiserver/app.py +++ b/backend/src/apiserver/app.py @@ -79,6 +79,7 @@ def handler_with_client( frontend_origin: str, smtp_config: SmtpConfig | None = None, smtp_send: bool = False, + environment: str = "production", ) -> Response: """Dispatch a request to its handler based on the route table.""" if store_queue is None: @@ -103,13 +104,14 @@ def h_set_sess(): smtp_config, smtp_send, frontend_origin, + environment, ) def h_clear_sess(): - return clear_session(req) + return clear_session(req, environment) def h_sess_info(): - return session_info(auth_client, req, headers, store_queue) + return session_info(auth_client, req, headers, store_queue, environment) def h_add_perm(): return add_user_permission(req, store_queue) @@ -432,6 +434,7 @@ def handler(req: Request, store_queue: StorageQueue | None) -> Response: settings.frontend_origin, settings.smtp, settings.smtp_send, + settings.environment, ) if ready_event is None: diff --git a/backend/src/apiserver/handlers/auth.py b/backend/src/apiserver/handlers/auth.py index 3dcb0a1..9d6e925 100644 --- a/backend/src/apiserver/handlers/auth.py +++ b/backend/src/apiserver/handlers/auth.py @@ -31,6 +31,7 @@ InvalidSessionToken, SESSION_COOKIE_PRIMARY, SESSION_COOKIE_SECONDARY, + apply_session_cookie_security, get_cookie_value, make_clear_session_cookie_header, validate_session, @@ -211,6 +212,7 @@ def set_session( smtp_config=None, smtp_send: bool = False, frontend_origin: str = "", + environment: str = "production", ) -> Response: """Handle /auth/set_session/ — validate and set session cookie.""" try: @@ -236,10 +238,7 @@ def set_session( cookie = SimpleCookie() cookie[cookie_name] = session_token - cookie[cookie_name]["httponly"] = True - cookie[cookie_name]["samesite"] = "None" - cookie[cookie_name]["secure"] = True - cookie[cookie_name]["path"] = "/" + apply_session_cookie_security(cookie, cookie_name, environment) cookie[cookie_name]["max-age"] = max_session_age cookie_header = cookie[cookie_name].OutputString() @@ -251,7 +250,7 @@ def set_session( ) -def clear_session(req: Request) -> Response: +def clear_session(req: Request, environment: str = "production") -> Response: """Handle /auth/clear_session/ — clear session cookie.""" secondary = False if req.body: @@ -263,7 +262,8 @@ def clear_session(req: Request) -> Response: cookie_name = SESSION_COOKIE_SECONDARY if secondary else SESSION_COOKIE_PRIMARY logger.info(f"clear_session: Clearing {cookie_name} cookie") - return Response.empty(headers=[make_clear_session_cookie_header(cookie_name)]) + header = make_clear_session_cookie_header(cookie_name, environment) + return Response.empty(headers=[header]) def session_info( @@ -271,6 +271,7 @@ def session_info( req: Request, headers: dict[str, str], store_queue: StorageQueue, + environment: str = "production", ) -> Response: """Handle /auth/session_info/ — returns current user info and permissions. @@ -294,7 +295,7 @@ def session_info( return Response.json( {"error": "invalid_session"}, status_code=401, - headers=[make_clear_session_cookie_header(cookie_name)], + headers=[make_clear_session_cookie_header(cookie_name, environment)], ) timestamp = int(time.time()) @@ -318,7 +319,7 @@ def get_session_data( return Response.json( {"error": "invalid_session"}, status_code=401, - headers=[make_clear_session_cookie_header(cookie_name)], + headers=[make_clear_session_cookie_header(cookie_name, environment)], ) session = session_result diff --git a/backend/src/apiserver/server.py b/backend/src/apiserver/server.py index a153932..947462d 100644 --- a/backend/src/apiserver/server.py +++ b/backend/src/apiserver/server.py @@ -6,6 +6,7 @@ import enum import logging +import sys import time from dataclasses import dataclass, field from http.cookies import SimpleCookie @@ -203,16 +204,29 @@ def get_cookie_value(headers: dict[str, str], cookie_name: str) -> str | None: return None +def apply_session_cookie_security( + cookie: SimpleCookie, cookie_name: str, environment: str +) -> None: + """Apply HttpOnly / SameSite / Secure / Path to a session cookie. + + Secure is dropped only on macOS in the test env: Safari rejects Secure + cookies on the HTTP loopback (https://bugs.webkit.org/show_bug.cgi?id=281149). + """ + cookie[cookie_name]["httponly"] = True + cookie[cookie_name]["samesite"] = "Strict" + cookie[cookie_name]["path"] = "/" + if not (environment == "test" and sys.platform == "darwin"): + cookie[cookie_name]["secure"] = True + + def make_clear_session_cookie_header( cookie_name: str = SESSION_COOKIE_PRIMARY, + environment: str = "production", ) -> tuple[bytes, bytes]: """Create a Set-Cookie header that clears a session cookie.""" cookie = SimpleCookie() cookie[cookie_name] = "" - cookie[cookie_name]["httponly"] = True - cookie[cookie_name]["samesite"] = "None" - cookie[cookie_name]["secure"] = True - cookie[cookie_name]["path"] = "/" + apply_session_cookie_security(cookie, cookie_name, environment) cookie[cookie_name]["max-age"] = 0 return (b"Set-Cookie", cookie[cookie_name].OutputString().encode("utf-8"))