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,
)