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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [UNRELEASED]

### Bug fixes

* Fixed `session.user` and `session.groups` raising `AttributeError` in module sessions (`SessionProxy`) and Express apps (`ExpressStubSession`). Both now correctly return the authenticated user's identity from the root session. As part of this fix, `user` and `groups` are now read-only properties on the `Session` ABC — app code can no longer accidentally overwrite credentials that are derived from immutable HTTP headers. (#2276)

## [1.6.3] - 2026-06-01

### New features
Expand Down
8 changes: 8 additions & 0 deletions shiny/express/_stub_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ def __init__(self, ns: ResolvedId = Root):

self.bookmark = BookmarkExpressStub(self)

@property
def user(self) -> str | None:
return None

@property
def groups(self) -> list[str] | None:
return None

def is_stub_session(self) -> Literal[True]:
return True

Expand Down
34 changes: 28 additions & 6 deletions shiny/session/_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,14 @@ class Session(ABC):
# iterate over all modules and check the `.bookmark_exclude` list of each proxy
# session.
bookmark: Bookmark
user: str | None
groups: list[str] | None

@property
@abstractmethod
def user(self) -> str | None: ...

@property
@abstractmethod
def groups(self) -> list[str] | None: ...

# TODO: not sure these should be directly exposed
_outbound_message_queues: OutBoundMessageQueues
Expand Down Expand Up @@ -740,8 +746,8 @@ def __init__(

self.bookmark: Bookmark = BookmarkApp(self)

self.user: str | None = None
self.groups: list[str] | None = None
self._user: str | None = None
self._groups: list[str] | None = None

credentials_json: str = ""
if "shiny-server-credentials" in self.http_conn.headers:
Expand All @@ -755,8 +761,8 @@ def __init__(
if credentials_json:
try:
creds = json.loads(credentials_json)
self.user = creds["user"]
self.groups = creds["groups"]
self._user = creds["user"]
self._groups = creds["groups"]
except Exception as e:
print("Error parsing credentials header: " + str(e), file=sys.stderr)

Expand Down Expand Up @@ -808,6 +814,14 @@ async def _run_session_ended_tasks(self) -> None:
def is_stub_session(self) -> Literal[False]:
return False

@property
def user(self) -> str | None:
return self._user

@property
def groups(self) -> list[str] | None:
return self._groups

async def close(self, code: int = 1001) -> None:
await self._conn.close(code, None)
await self._run_session_ended_tasks()
Expand Down Expand Up @@ -1518,6 +1532,14 @@ def __init__(self, root_session: Session, ns: ResolvedId) -> None:

self.bookmark = BookmarkProxy(self)

@property
def user(self) -> str | None:
return self._root_session.user

@property
def groups(self) -> list[str] | None:
return self._root_session.groups

def on_destroy(
self, fn: Callable[[], None] | Callable[[], Awaitable[None]]
) -> None:
Expand Down
47 changes: 46 additions & 1 deletion tests/pytest/test_shinysession.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,58 @@
"""Tests for `shiny.Session`."""

import asyncio
import json

import pytest

from shiny import ui
from shiny import App, Inputs, Outputs, Session, module, ui
from shiny._connection import MockConnection
from shiny.reactive import effect, flush, isolate
from shiny.session import Inputs
from shiny.types import SilentException


def test_stub_session_user_groups():
from shiny.express._stub_session import ExpressStubSession

stub = ExpressStubSession()
assert stub.user is None
assert stub.groups is None


@pytest.mark.asyncio
async def test_module_session_user_groups():
"""SessionProxy (module session) should delegate user/groups to the root session."""
captured: dict[str, object] = {}

@module.server
def mod(input: Inputs, output: Outputs, session: Session):
captured["mod_user"] = session.user
captured["mod_groups"] = session.groups

def server(input: Inputs, output: Outputs, session: Session):
mod("m")
captured["root_user"] = session.user
captured["root_groups"] = session.groups

creds = json.dumps({"user": "alice", "groups": ["admin", "dev"]}).encode()
headers = [(b"shiny-server-credentials", creds)]
conn = MockConnection()
conn._http_conn.scope["headers"] = headers
sess = App(ui.TagList(), server)._create_session(conn)

async def mock_client():
conn.cause_receive('{"method":"init","data":{}}')
conn.cause_disconnect()

await asyncio.gather(mock_client(), sess._run())

assert captured["root_user"] == "alice"
assert captured["root_groups"] == ["admin", "dev"]
assert captured["mod_user"] == "alice"
assert captured["mod_groups"] == ["admin", "dev"]


def test_require_active_session_error_messages():
# require_active_session() should report the caller's name when an error occurs.
with pytest.raises(RuntimeError, match=r"Progress\(\) must be called"):
Expand Down
Loading