From ca6d8b963533e3b95a8d6197a3218191c89979fd Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Fri, 8 May 2026 14:32:08 +0200 Subject: [PATCH] fix cross site tokens --- env-gateway-int.env | 4 ++- env-gateway-prod.env | 3 +- modules/auth/jwtService.py | 67 +++++++++++++++++++++++--------------- 3 files changed, 46 insertions(+), 28 deletions(-) diff --git a/env-gateway-int.env b/env-gateway-int.env index 10272b34..d22b7d2a 100644 --- a/env-gateway-int.env +++ b/env-gateway-int.env @@ -4,6 +4,8 @@ APP_ENV_TYPE = int APP_ENV_LABEL = Integration Instance APP_API_URL = https://gateway-int.poweron.swiss +# Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https:// +APP_COOKIE_SECURE = true APP_KEY_SYSVAR = CONFIG_KEY APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9 APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9 @@ -19,7 +21,7 @@ APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZ APP_TOKEN_EXPIRY=300 # CORS Configuration -APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss,https://playground-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss +APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss,https://playground-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net # Logging configuration APP_LOGGING_LOG_LEVEL = DEBUG diff --git a/env-gateway-prod.env b/env-gateway-prod.env index 67202109..0183ae1f 100644 --- a/env-gateway-prod.env +++ b/env-gateway-prod.env @@ -7,6 +7,7 @@ APP_KEY_SYSVAR = CONFIG_KEY APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09 APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9 APP_API_URL = https://gateway-prod.poweron.swiss +APP_COOKIE_SECURE = true # PostgreSQL DB Host DB_HOST=gateway-prod-server.postgres.database.azure.com @@ -19,7 +20,7 @@ APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUl APP_TOKEN_EXPIRY=300 # CORS Configuration -APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss,https://playground-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss +APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss,https://playground-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net # Logging configuration APP_LOGGING_LOG_LEVEL = DEBUG diff --git a/modules/auth/jwtService.py b/modules/auth/jwtService.py index 422a4951..6ea4535d 100644 --- a/modules/auth/jwtService.py +++ b/modules/auth/jwtService.py @@ -19,15 +19,28 @@ ALGORITHM = APP_CONFIG.get("Auth_ALGORITHM") ACCESS_TOKEN_EXPIRE_MINUTES = int(APP_CONFIG.get("APP_TOKEN_EXPIRY")) REFRESH_TOKEN_EXPIRE_DAYS = int(APP_CONFIG.get("APP_REFRESH_TOKEN_EXPIRY", "7")) -# Cookie security settings - use secure cookies based on whether API uses HTTPS -# Cookies must have secure=True on HTTPS sites, secure=False on HTTP sites -APP_API_URL = APP_CONFIG.get("APP_API_URL", "http://localhost:8000") -USE_SECURE_COOKIES = APP_API_URL.startswith("https://") if APP_API_URL else False +def _cookiePolicy() -> Tuple[bool, str, str]: + """ + Return (useSecure, samesiteStarlette, samesiteSetCookieHeader). -# Cross-origin SPA (any host) + API (gateway host): SameSite=None + Secure is required so the -# browser sends cookies on credentialed XHR/fetch. HTTP localhost uses Lax (None without Secure is rejected). -COOKIE_SAMESITE = "none" if USE_SECURE_COOKIES else "lax" -COOKIE_SAMESITE_SET_COOKIE = "None" if USE_SECURE_COOKIES else "Lax" + Evaluated on each Set-Cookie so policy is not frozen at module import (config refresh / load order). + + Cross-origin SPA + API: SameSite=None and Secure=True so credentialed fetch sends cookies. + HTTP dev: Lax + Secure=False. + + APP_COOKIE_SECURE: explicit true/false (1/0, yes/no) overrides the APP_API_URL heuristic. + """ + explicit = (APP_CONFIG.get("APP_COOKIE_SECURE") or "").strip().lower() + if explicit in ("1", "true", "yes"): + useSecure = True + elif explicit in ("0", "false", "no"): + useSecure = False + else: + apiUrl = (APP_CONFIG.get("APP_API_URL") or "").strip() + useSecure = apiUrl.startswith("https://") + samesite = "none" if useSecure else "lax" + samesiteHeader = "None" if useSecure else "Lax" + return useSecure, samesite, samesiteHeader def createAccessToken(data: dict, expiresDelta: Optional[timedelta] = None) -> Tuple[str, "datetime"]: @@ -59,13 +72,14 @@ def createRefreshToken(data: dict) -> Tuple[str, "datetime"]: def setAccessTokenCookie(response: Response, token: str, expiresDelta: Optional[timedelta] = None) -> None: """Set access token as httpOnly cookie.""" + useSecure, samesite, _ = _cookiePolicy() maxAge = int(expiresDelta.total_seconds()) if expiresDelta else ACCESS_TOKEN_EXPIRE_MINUTES * 60 response.set_cookie( key="auth_token", value=token, httponly=True, - secure=USE_SECURE_COOKIES, # Only secure in production (HTTPS) - samesite=COOKIE_SAMESITE, + secure=useSecure, + samesite=samesite, path="/", max_age=maxAge ) @@ -73,12 +87,13 @@ def setAccessTokenCookie(response: Response, token: str, expiresDelta: Optional[ def setRefreshTokenCookie(response: Response, token: str) -> None: """Set refresh token as httpOnly cookie.""" + useSecure, samesite, _ = _cookiePolicy() response.set_cookie( key="refresh_token", value=token, httponly=True, - secure=USE_SECURE_COOKIES, # Only secure in production (HTTPS) - samesite=COOKIE_SAMESITE, + secure=useSecure, + samesite=samesite, path="/", max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 ) @@ -89,22 +104,22 @@ def clearAccessTokenCookie(response: Response) -> None: Clear access token cookie by setting it to expire immediately. Uses both raw header manipulation and FastAPI's delete_cookie for maximum browser compatibility. """ - # Build secure flag based on environment - secure_flag = "; Secure" if USE_SECURE_COOKIES else "" - + useSecure, samesite, samesiteHeader = _cookiePolicy() + secure_flag = "; Secure" if useSecure else "" + # Primary method: Raw Set-Cookie header for guaranteed deletion response.headers.append( "Set-Cookie", - f"auth_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite={COOKIE_SAMESITE_SET_COOKIE}" + f"auth_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite={samesiteHeader}" ) - + # Fallback: Also use FastAPI's built-in method (match SameSite/Secure for invalidation) response.delete_cookie( key="auth_token", path="/", - secure=USE_SECURE_COOKIES, + secure=useSecure, httponly=True, - samesite=COOKIE_SAMESITE, + samesite=samesite, ) @@ -113,22 +128,22 @@ def clearRefreshTokenCookie(response: Response) -> None: Clear refresh token cookie by setting it to expire immediately. Uses both raw header manipulation and FastAPI's delete_cookie for maximum browser compatibility. """ - # Build secure flag based on environment - secure_flag = "; Secure" if USE_SECURE_COOKIES else "" - + useSecure, samesite, samesiteHeader = _cookiePolicy() + secure_flag = "; Secure" if useSecure else "" + # Primary method: Raw Set-Cookie header for guaranteed deletion response.headers.append( "Set-Cookie", - f"refresh_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite={COOKIE_SAMESITE_SET_COOKIE}" + f"refresh_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite={samesiteHeader}" ) - + # Fallback: Also use FastAPI's built-in method (match SameSite/Secure for invalidation) response.delete_cookie( key="refresh_token", path="/", - secure=USE_SECURE_COOKIES, + secure=useSecure, httponly=True, - samesite=COOKIE_SAMESITE, + samesite=samesite, )