Skip to content

Commit 2fb9f9d

Browse files
Enable setting session cookie attributes from ui.run() (zauberzeug#5213)
### Motivation When using `app.storage.user` it creates a session cookie with permissions that disallow cross-origin usage. While this is a good secure default, there's currently no way to override it when needed, e.g. if you want your NiceGUI site to be embeddable as an iframe from another domain. See discussion zauberzeug#4252. ### Implementation A new argument is added to `ui.run()` which allows passing keyword arguments that will be forwarded to the [SessionMiddleware](https://www.starlette.dev/middleware/#sessionmiddleware) that is used to create the session cookie. ### Progress - [x] I chose a meaningful title that completes the sentence: "If applied, this PR will..." - [x] The implementation is complete. - [x] Pytests have been added (or are not necessary). - [x] Documentation has been added (or is not necessary). --------- Co-authored-by: Falko Schindler <[email protected]>
1 parent e55bfd7 commit 2fb9f9d

File tree

5 files changed

+36
-7
lines changed

5 files changed

+36
-7
lines changed

nicegui/server.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import multiprocessing
44
import socket
5+
from typing import Any
56

67
import uvicorn
78

@@ -13,6 +14,7 @@ class CustomServerConfig(uvicorn.Config):
1314
storage_secret: str | None = None
1415
method_queue: multiprocessing.Queue | None = None
1516
response_queue: multiprocessing.Queue | None = None
17+
session_middleware_kwargs: dict[str, Any] | None = None
1618

1719

1820
class Server(uvicorn.Server):
@@ -31,5 +33,5 @@ def run(self, sockets: list[socket.socket] | None = None) -> None:
3133
native.method_queue = self.config.method_queue
3234
native.response_queue = self.config.response_queue
3335

34-
storage.set_storage_secret(self.config.storage_secret)
36+
storage.set_storage_secret(self.config.storage_secret, self.config.session_middleware_kwargs)
3537
super().run(sockets=sockets)

nicegui/storage.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import uuid
44
from datetime import timedelta
55
from pathlib import Path
6-
from typing import Optional, Union
6+
from typing import Any, Optional, Union
77

88
from starlette.middleware import Middleware
99
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
@@ -35,14 +35,15 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
3535
return response
3636

3737

38-
def set_storage_secret(storage_secret: Optional[str] = None) -> None:
38+
def set_storage_secret(storage_secret: Optional[str] = None,
39+
session_middleware_kwargs: Optional[dict[str, Any]] = None) -> None:
3940
"""Set storage_secret and add request tracking middleware."""
4041
if any(m.cls == SessionMiddleware for m in core.app.user_middleware):
4142
# NOTE not using "add_middleware" because it would be the wrong order
4243
core.app.user_middleware.append(Middleware(RequestTrackingMiddleware))
4344
elif storage_secret is not None:
4445
core.app.add_middleware(RequestTrackingMiddleware)
45-
core.app.add_middleware(SessionMiddleware, secret_key=storage_secret)
46+
core.app.add_middleware(SessionMiddleware, secret_key=storage_secret, **(session_middleware_kwargs or {}))
4647
Storage.secret = storage_secret
4748

4849

nicegui/ui_run.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def run(root: Optional[Callable] = None, *,
7575
prod_js: bool = True,
7676
endpoint_documentation: Literal['none', 'internal', 'page', 'all'] = 'none',
7777
storage_secret: Optional[str] = None,
78+
session_middleware_kwargs: Optional[dict[str, Any]] = None,
7879
show_welcome_message: bool = True,
7980
**kwargs: Any,
8081
) -> None:
@@ -111,6 +112,7 @@ def run(root: Optional[Callable] = None, *,
111112
:param prod_js: whether to use the production version of Vue and Quasar dependencies (default: `True`)
112113
:param endpoint_documentation: control what endpoints appear in the autogenerated OpenAPI docs (default: 'none', options: 'none', 'internal', 'page', 'all')
113114
:param storage_secret: secret key for browser-based storage (default: `None`, a value is required to enable ui.storage.individual and ui.storage.browser)
115+
:param session_middleware_kwargs: additional keyword arguments passed to SessionMiddleware that creates the session cookies used for browser-based storage
114116
:param show_welcome_message: whether to show the welcome message (default: `True`)
115117
:param kwargs: additional keyword arguments are passed to `uvicorn.run`
116118
"""
@@ -181,7 +183,7 @@ def run_script() -> None:
181183
core.app.setup()
182184

183185
if helpers.is_user_simulation():
184-
set_storage_secret(storage_secret)
186+
set_storage_secret(storage_secret, session_middleware_kwargs)
185187
return
186188

187189
if on_air:
@@ -256,6 +258,7 @@ def split_args(args: str) -> list[str]:
256258
config.storage_secret = storage_secret
257259
config.method_queue = native_module.native.method_queue if native else None
258260
config.response_queue = native_module.native.response_queue if native else None
261+
config.session_middleware_kwargs = session_middleware_kwargs
259262
Server.create_singleton(config)
260263

261264
if (reload or config.workers > 1) and not isinstance(config.app, str):

nicegui/ui_run_with.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from contextlib import asynccontextmanager
22
from pathlib import Path
3-
from typing import Callable, Literal, Optional, Union
3+
from typing import Any, Callable, Literal, Optional, Union
44

55
from fastapi import FastAPI
66
from fastapi.middleware.gzip import GZipMiddleware
@@ -29,6 +29,7 @@ def run_with(
2929
tailwind: bool = True,
3030
prod_js: bool = True,
3131
storage_secret: Optional[str] = None,
32+
session_middleware_kwargs: Optional[dict[str, Any]] = None,
3233
show_welcome_message: bool = True,
3334
) -> None:
3435
"""Run NiceGUI with FastAPI.
@@ -49,6 +50,7 @@ def run_with(
4950
:param tailwind: whether to use Tailwind CSS (experimental, default: `True`)
5051
:param prod_js: whether to use the production version of Vue and Quasar dependencies (default: `True`)
5152
:param storage_secret: secret key for browser-based storage (default: `None`, a value is required to enable ui.storage.individual and ui.storage.browser)
53+
:param session_middleware_kwargs: additional keyword arguments passed to SessionMiddleware that creates the session cookies used for browser-based storage
5254
:param show_welcome_message: whether to show the welcome message (default: `True`)
5355
"""
5456
core.app.config.add_run_config(
@@ -67,7 +69,7 @@ def run_with(
6769
cache_control_directives=cache_control_directives,
6870
)
6971
core.root = root
70-
storage.set_storage_secret(storage_secret)
72+
storage.set_storage_secret(storage_secret, session_middleware_kwargs)
7173
core.app.add_middleware(GZipMiddleware)
7274
core.app.add_middleware(RedirectWithPrefixMiddleware)
7375
core.app.add_middleware(SetCacheControlMiddleware)

tests/test_storage.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pathlib import Path
55

66
import httpx
7+
import pytest
78

89
from nicegui import Client, app, background_tasks, context, core, nicegui, ui
910
from nicegui.persistence.file_persistent_dict import FilePersistentDict
@@ -385,3 +386,23 @@ def page():
385386
await background_tasks.teardown()
386387
assert path.exists(), 'backup should be written during teardown'
387388
assert path.read_text(encoding='utf-8') == '{"key":"value"}'
389+
390+
391+
@pytest.mark.parametrize('custom_cookie_headers', [False, True])
392+
def test_storage_cookie_headers(screen: Screen, custom_cookie_headers: bool):
393+
@ui.page('/')
394+
def page():
395+
ui.label('Hello, world!')
396+
397+
screen.ui_run_kwargs['storage_secret'] = 'just a test'
398+
if custom_cookie_headers:
399+
screen.ui_run_kwargs['session_middleware_kwargs'] = {'same_site': 'none', 'https_only': True}
400+
screen.open('/')
401+
with httpx.Client() as http_client:
402+
response = http_client.get(f'http://localhost:{Screen.PORT}/')
403+
assert response.status_code == 200
404+
cookie_settings = str(response.headers.get('set-cookie')).lower()
405+
if custom_cookie_headers:
406+
assert cookie_settings.endswith('httponly; samesite=none; secure')
407+
else:
408+
assert cookie_settings.endswith('httponly; samesite=lax')

0 commit comments

Comments
 (0)