Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/+session-secret-persistence.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Preserve the local session signing secret when regenerating frontend files and exclude it from exported app archives.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add signed browser sessions and session-bound client tokens, with secret rotation through `REFLEX_SESSION_SECRET` and configurable session lifetimes. Signed session credentials are scoped to the app name, even when apps share signing keys.
6 changes: 6 additions & 0 deletions packages/reflex-base/src/reflex_base/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ class BaseConfig:
redis_lock_expiration: Maximum expiration lock time for redis state manager.
redis_lock_warning_threshold: Maximum lock time before warning for redis state manager.
redis_token_expiration: Token expiration time for redis state manager.
session_token_ttl: Browser session lifetime in seconds, defaulting to seven days.
session_token_refresh_interval: Session age in seconds before refreshing the cookie. None uses half the session TTL.
env_file: Path to file containing key-values pairs to load into the environment; Dotenv format. Multiple files may be separated by os.pathsep. Requires the python-dotenv package.
state_auto_setters: Whether to automatically create setters for state base vars.
default_color_mode: The default color mode for the app: "system" (follow the OS preference), "light", or "dark". Applies to the built-in color mode switcher and `color_mode_cond` without requiring a radix theme.
Expand Down Expand Up @@ -257,6 +259,10 @@ class BaseConfig:

redis_token_expiration: int = constants.Expiration.TOKEN

session_token_ttl: int = 7 * 24 * 60 * 60 # Seven days, in seconds.

session_token_refresh_interval: int | None = None

# Attributes that were explicitly set by the user.
_non_default_attributes: set[str] = dataclasses.field(
default_factory=set, init=False
Expand Down
8 changes: 8 additions & 0 deletions packages/reflex-base/src/reflex_base/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,14 @@ class EnvironmentVariables:
# This env var stores the execution mode of the app
REFLEX_ENV_MODE: EnvVar[constants.Env] = env_var(constants.Env.DEV)

# Signing keys for session cookies, newest first, separated by commas.
REFLEX_SESSION_SECRET: EnvVar[str | None] = env_var(None)

# Rollout mode for session-bound client tokens.
REFLEX_SESSION_TOKEN_MODE: EnvVar[Literal["off", "warn", "enforce"]] = env_var(
"warn"
)

# Whether to keep React's development-build owner-stack capture in dev mode.
# Reflex disables it by default because the per-element Error() capture
# dominates dev-mode render CPU on large pages; enable it to restore full
Expand Down
352 changes: 352 additions & 0 deletions packages/reflex-base/src/reflex_base/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,352 @@
"""Signed browser sessions and the client tokens bound to them."""

from __future__ import annotations

import base64
import binascii
import hashlib
import hmac
import json
import logging
import os
import re
import secrets
import tempfile
import time
from contextlib import suppress
from dataclasses import dataclass, field
from functools import cached_property
from typing import ClassVar

from reflex_base import constants
from reflex_base.environment import environment

logger = logging.getLogger(__name__)

SESSION_SECRET_FILENAME = "session_secret"
_SID = re.compile(r"[0-9a-f]{32}\Z")
_CLIENT_TOKEN = re.compile(r"([0-9a-f]{32})\.([0-9a-f]{32})\Z")
_BASE64URL = re.compile(r"[A-Za-z0-9_-]+\Z")
_AUTH_CACHE_SIZE = 128


def _base64url(value: bytes) -> str:
"""Encode bytes without base64url padding.

Args:
value: Bytes to encode.

Returns:
Encoded bytes as ASCII text.
"""
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")


def _decode_base64url(value: str) -> bytes:
"""Decode a JWT segment using only the base64url alphabet.

Args:
value: Encoded segment.

Returns:
The decoded bytes.

Raises:
ValueError: If the segment is malformed.
"""
if not _BASE64URL.fullmatch(value):
msg = "Invalid session token encoding"
raise ValueError(msg)
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))


def _client_signature(sid: str, nonce: str) -> str:
"""Sign a client token with an unambiguous session binding.

Args:
sid: Private session identifier.
nonce: Random client identifier.

Returns:
A 128-bit hexadecimal authentication tag.
"""
return hmac.digest(
bytes.fromhex(sid), f"reflex-client-token:{nonce}".encode("ascii"), "sha256"
)[:16].hex()


def _load_secrets() -> tuple[bytes, ...]:
"""Load the configured key ring or atomically create a local signing key.

Returns:
Keys in signing preference order.

Raises:
ValueError: If configured or persisted keys are invalid.
"""
configured = environment.REFLEX_SESSION_SECRET.get()
if configured is not None:
keys = tuple(key.strip().encode() for key in configured.split(","))
if any(len(key) < 32 for key in keys):
msg = "REFLEX_SESSION_SECRET keys must each be at least 32 bytes"
raise ValueError(msg)
return keys

path = (
environment.REFLEX_WEB_WORKDIR.get()
/ constants.Dirs.BACKEND
/ SESSION_SECRET_FILENAME
)
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists():
# Link a complete 0600 file into place, so racing workers cannot read a
# partially written key or replace the winner's key.
with tempfile.NamedTemporaryFile(dir=path.parent) as candidate:
candidate.write(secrets.token_hex(32).encode("ascii"))
candidate.flush()
with suppress(FileExistsError):
os.link(candidate.name, path)
try:
key = bytes.fromhex(path.read_text(encoding="ascii"))
except (ValueError, UnicodeError) as error:
msg = "Invalid persisted session signing secret"
raise ValueError(msg) from error
if len(key) != 32:
msg = "Invalid persisted session signing secret"
raise ValueError(msg)
if environment.REFLEX_ENV_MODE.get() == constants.Env.PROD:
logger.warning(
"Using a local session signing secret. Set REFLEX_SESSION_SECRET to "
"share sessions across production replicas and deployments."
)
return (key,)


@dataclass(frozen=True)
class SessionToken:
"""A verified browser session, distinct from an application's user identity.

Attributes:
id: A non-secret hash suitable for correlation in logs.
issued_at: Unix timestamp when this session credential was issued.
expires_at: Unix timestamp when this session credential expires.
SYSTEM: Sentinel for trusted operations without a browser session.
"""

issued_at: int
expires_at: int
_sid: str = field(repr=False)
id: str = field(init=False)
_authorized: set[str] = field(default_factory=set, repr=False, compare=False)
SYSTEM: ClassVar[SessionToken]

def __post_init__(self) -> None:
"""Derive the public identifier without exposing the private session ID."""
object.__setattr__(self, "id", hashlib.sha256(self._sid.encode()).hexdigest())

def authorizes(self, client_token: str) -> bool:
"""Check a client's binding to this session, caching verified successes.

Expiration is checked when decoding the session credential; a connected
transport controls when to refresh or revalidate that credential.

Args:
client_token: The client token to verify.

Returns:
Whether this session owns the client token.
"""
if self is SessionToken.SYSTEM or client_token in self._authorized:
Comment thread
masenf marked this conversation as resolved.
return True
match = _CLIENT_TOKEN.fullmatch(client_token)
if match is None:
return False
nonce, signature = match.groups()
if not hmac.compare_digest(signature, _client_signature(self._sid, nonce)):
return False
if len(self._authorized) >= _AUTH_CACHE_SIZE:
self._authorized.clear()
self._authorized.add(client_token)
return True


SessionToken.SYSTEM = SessionToken(issued_at=0, expires_at=0, _sid="")


class SessionTokenManager:
"""Internal codec for browser session cookies and their client tokens."""

def __init__(
self,
app_name: str,
ttl: int = 7 * 24 * 60 * 60, # Seven days, in seconds.
refresh_interval: int | None = None,
secrets: tuple[bytes, ...] | None = None,
) -> None:
"""Configure the codec without reading secrets until first use.

Args:
app_name: Stable app name used to isolate its cookie and signed audience.
ttl: Session lifetime in seconds.
refresh_interval: Age in seconds to refresh, defaulting to half the TTL.
secrets: Optional explicit key ring, primarily for isolated tests.

Raises:
ValueError: If the session lifetime or refresh interval is invalid.
"""
if ttl <= 0 or (
refresh_interval is not None and not 0 < refresh_interval < ttl
):
msg = (
"Session TTL must be positive and refresh interval between zero and TTL"
)
raise ValueError(msg)
self._audience = app_name
self.ttl = ttl
self.refresh_interval = (
ttl / 2 if refresh_interval is None else refresh_interval
)
self.cookie_name = (
f"reflex_session_{hashlib.sha256(app_name.encode()).hexdigest()[:12]}"
)
self._configured_secrets = secrets
self._unknown_key_warned = False

@cached_property
def _keys(self) -> dict[str, bytes]:
"""Read the key ring once and index it by its non-secret fingerprint.

Returns:
Signing keys indexed by key ID, with the active signing key first.
"""
return {
hashlib.sha256(key).hexdigest()[:16]: key
for key in self._configured_secrets or _load_secrets()
}

def create(self) -> SessionToken:
"""Create a new random session.

Returns:
A new session with the configured lifetime.
"""
now = int(time.time())
return SessionToken(now, now + self.ttl, secrets.token_hex(16))

def encode(self, session: SessionToken) -> str:
"""Encode a session as an HS256 JWT using the active signing key.

Args:
session: The session to encode.

Returns:
A signed JWT suitable for an HttpOnly cookie.
"""
kid, key = next(iter(self._keys.items()))
header = _base64url(
json.dumps(
{"alg": "HS256", "typ": "JWT", "kid": kid}, separators=(",", ":")
).encode()
)
payload = _base64url(
json.dumps(
{
"v": 1,
"aud": self._audience,
Comment thread
masenf marked this conversation as resolved.
"sid": session._sid,
"iat": session.issued_at,
"exp": session.expires_at,
},
Comment thread
masenf marked this conversation as resolved.
separators=(",", ":"),
).encode()
)
message = f"{header}.{payload}"
return f"{message}.{_base64url(hmac.digest(key, message.encode('ascii'), 'sha256'))}"

def decode(self, token: str) -> SessionToken | None:
"""Verify the signature, schema, version, and lifetime of a session JWT.

Args:
token: An untrusted cookie value.

Returns:
The verified session, or None for invalid or expired credentials.
"""
if len(token) > 4096:
return None
try:
header, payload, signature = token.split(".")
metadata = json.loads(_decode_base64url(header))
if not isinstance(metadata, dict) or metadata.get("alg") != "HS256":
return None
kid = metadata.get("kid")
if not isinstance(kid, str):
return None
if (key := self._keys.get(kid)) is None:
if not self._unknown_key_warned:
logger.warning(
"Unrecognized session signing key. Backend instances may be "
"using different REFLEX_SESSION_SECRET values."
)
self._unknown_key_warned = True
return None
expected = hmac.digest(key, f"{header}.{payload}".encode("ascii"), "sha256")
if not hmac.compare_digest(expected, _decode_base64url(signature)):
return None
claims = json.loads(_decode_base64url(payload))
if not isinstance(claims, dict):
return None
sid, issued_at, expires_at = (
claims.get("sid"),
claims.get("iat"),
claims.get("exp"),
)
if (
type(claims.get("v")) is not int
or claims["v"] != 1
or claims.get("aud") != self._audience
or not isinstance(sid, str)
or not _SID.fullmatch(sid)
or type(issued_at) is not int
or type(expires_at) is not int
or not issued_at <= int(time.time()) < expires_at
):
return None
return SessionToken(issued_at, expires_at, sid)
except (ValueError, UnicodeError, binascii.Error, RecursionError):
return None

def should_refresh(self, session: SessionToken) -> bool:
"""Check whether a session has reached its configured refresh age.

Args:
session: The verified session to check.

Returns:
Whether to issue a refreshed cookie.
"""
return time.time() - session.issued_at >= self.refresh_interval

def refresh(self, session: SessionToken) -> SessionToken:
"""Renew a session while preserving its client-token bindings.

Args:
session: The verified session to renew.

Returns:
The session with updated issuance and expiration times.
"""
now = int(time.time())
return SessionToken(now, now + self.ttl, session._sid)

def create_client_token(self, session: SessionToken) -> str:
"""Create a random client token cryptographically bound to a session.

Args:
session: The verified session that owns the new client.

Returns:
An underscore-free client identifier and its authentication tag.
"""
nonce = secrets.token_hex(16)
return f"{nonce}.{_client_signature(session._sid, nonce)}"
Loading
Loading