Refactor to composable KayaMixin architecture

Replace wrapper-based SessionMiddleware/OIDCApp with KayaMixin subclasses
applied via KayaApp(mixins=[...]). Mixins hook into handle_request and
handle_websocket via before/after hooks, so both ASGI and RSGI keep working.
Mixin dependencies are applied automatically and deduplicated.
This commit is contained in:
2026-07-23 22:09:59 +08:00
parent 24a797e3d2
commit 3ebf079533
14 changed files with 480 additions and 359 deletions
+10 -10
View File
@@ -1,30 +1,30 @@
import os import os
from kaya.core import HttpContext, KayaApp from kaya.core import HttpContext, KayaApp
from kaya.oidc import OIDCConfig, OIDCApp from kaya.oidc import OIDCConfig, OIDCMixin
from kaya.session import InMemorySessionStore, SessionMiddleware from kaya.session import InMemorySessionStore, SessionMixin
app = KayaApp() session = SessionMixin(InMemorySessionStore())
session_app = SessionMiddleware(app, InMemorySessionStore()) oidc = OIDCMixin(
oidc = OIDCApp(
session_app,
OIDCConfig( OIDCConfig(
issuer=os.environ.get('OIDC_ISSUER', 'https://accounts.google.com'), issuer=os.environ.get('OIDC_ISSUER', 'https://accounts.google.com'),
client_id=os.environ.get('OIDC_CLIENT_ID', 'replace-me'), client_id=os.environ.get('OIDC_CLIENT_ID', 'replace-me'),
client_secret=os.environ.get('OIDC_CLIENT_SECRET'), client_secret=os.environ.get('OIDC_CLIENT_SECRET'),
redirect_uri=os.environ.get('OIDC_REDIRECT_URI', 'http://localhost:8000/auth/callback'), redirect_uri=os.environ.get('OIDC_REDIRECT_URI', 'http://localhost:8000/auth/callback'),
fetch_userinfo=True, fetch_userinfo=True,
) ),
session=session,
) )
app = KayaApp(mixins=[session, oidc])
@oidc.GET('/')
@app.GET('/')
async def home(ctx: HttpContext) -> None: async def home(ctx: HttpContext) -> None:
await ctx.send_str(200, 'public home') await ctx.send_str(200, 'public home')
@oidc.GET('/profile') @app.GET('/profile')
@oidc.require_auth @oidc.require_auth
async def profile(ctx: HttpContext) -> None: async def profile(ctx: HttpContext) -> None:
user = oidc.get_user(ctx) user = oidc.get_user(ctx)
+2 -2
View File
@@ -1,7 +1,7 @@
from kaya.core import HttpContext, KayaApp from kaya.core import HttpContext, KayaApp
from kaya.session import InMemorySessionStore, SessionMiddleware from kaya.session import InMemorySessionStore, SessionMixin
app = SessionMiddleware(KayaApp(), InMemorySessionStore()) app = KayaApp(mixins=[SessionMixin(InMemorySessionStore())])
@app.GET('/') @app.GET('/')
@@ -1,6 +1,7 @@
from ._app import AbstractKayaApp, KayaApp from ._app import AbstractKayaApp, KayaApp
from ._http_method import HttpMethod from ._http_method import HttpMethod
from ._http_context import HttpContext from ._http_context import HttpContext
from ._mixin import KayaMixin
from ._tree import Tree, PathIterator from ._tree import Tree, PathIterator
from ._path_handler import PathHandler, Matches from ._path_handler import PathHandler, Matches
from ._websocket import WebSocket, WebSocketMessage from ._websocket import WebSocket, WebSocketMessage
@@ -10,6 +11,7 @@ __all__ = [
'AbstractKayaApp', 'AbstractKayaApp',
'HttpMethod', 'HttpMethod',
'KayaApp', 'KayaApp',
'KayaMixin',
'HttpContext', 'HttpContext',
'Tree', 'Tree',
'PathHandler', 'PathHandler',
+78 -14
View File
@@ -6,9 +6,10 @@ from typing import Callable, Awaitable, Any, Mapping, Sequence, Optional, Tuple,
from pwo import Maybe, AsyncQueueIterator from pwo import Maybe, AsyncQueueIterator
from ._http_context import HttpContext from ._http_context import HttpContext
from ._http_method import HttpMethod from ._http_method import HttpMethod
from ._mixin import KayaMixin
from ._path_handler import Context from ._path_handler import Context
from ._types import StrOrStrings from ._types import StrOrStrings
from ._websocket import WebSocket from ._websocket import WebSocket, WebSocketMessage
from ._asgi import AsgiContext, AsgiWebSocket from ._asgi import AsgiContext, AsgiWebSocket
from ._tree import Tree from ._tree import Tree
from ._types.asgi import LifespanScope, HTTPScope as ASGIHTTPScope, WebSocketScope as ASGIWebSocketScope from ._types.asgi import LifespanScope, HTTPScope as ASGIHTTPScope, WebSocketScope as ASGIWebSocketScope
@@ -18,6 +19,10 @@ log = getLogger(__name__)
type HttpHandler = Callable[[HttpContext, Unpack[Any]], Awaitable[None]] type HttpHandler = Callable[[HttpContext, Unpack[Any]], Awaitable[None]]
type WebSocketHandler = Callable[[WebSocket, Unpack[Any]], Awaitable[None]] type WebSocketHandler = Callable[[WebSocket, Unpack[Any]], Awaitable[None]]
type BeforeRequestHook = Callable[[HttpContext], Awaitable[Optional[HttpContext]]]
type AfterRequestHook = Callable[[HttpContext], Awaitable[None]]
type BeforeWebSocketHook = Callable[[WebSocket], Awaitable[Optional[WebSocket]]]
type AfterWebSocketHook = Callable[[WebSocket], Awaitable[None]]
class AbstractKayaApp(ABC): class AbstractKayaApp(ABC):
@@ -89,25 +94,84 @@ class AbstractKayaApp(ABC):
class KayaApp(AbstractKayaApp): class KayaApp(AbstractKayaApp):
_tree: Tree _tree: Tree
_mixins: list[KayaMixin]
_applied_ids: set[int]
_before_request_hooks: list[BeforeRequestHook]
_after_request_hooks: list[AfterRequestHook]
_before_websocket_hooks: list[BeforeWebSocketHook]
_after_websocket_hooks: list[AfterWebSocketHook]
def __init__(self) -> None: def __init__(self, mixins: Sequence[KayaMixin] = ()) -> None:
self._tree = Tree() self._tree = Tree()
self._mixins = []
self._applied_ids = set()
self._before_request_hooks = []
self._after_request_hooks = []
self._before_websocket_hooks = []
self._after_websocket_hooks = []
for mixin in mixins:
self._apply_mixin(mixin)
def _apply_mixin(self, mixin: KayaMixin) -> None:
if id(mixin) in self._applied_ids:
return
for dependency in mixin.dependencies:
self._apply_mixin(dependency)
mixin.apply(self)
self._applied_ids.add(id(mixin))
self._mixins.append(mixin)
def add_before_request_hook(self, hook: BeforeRequestHook) -> None:
self._before_request_hooks.append(hook)
def add_after_request_hook(self, hook: AfterRequestHook) -> None:
self._after_request_hooks.append(hook)
def add_before_websocket_hook(self, hook: BeforeWebSocketHook) -> None:
self._before_websocket_hooks.append(hook)
def add_after_websocket_hook(self, hook: AfterWebSocketHook) -> None:
self._after_websocket_hooks.append(hook)
async def handle_request(self, ctx: HttpContext) -> None: async def handle_request(self, ctx: HttpContext) -> None:
result = self._tree.get_handler(ctx.path, ctx.method) for hook in self._before_request_hooks:
if result is not None: new_ctx = await hook(ctx)
handler, captured = result if new_ctx is not None:
await handler.handle_request(ctx, captured) ctx = new_ctx
else: try:
await ctx.send_empty(404) result = self._tree.get_handler(ctx.path, ctx.method)
if result is not None:
handler, captured = result
await handler.handle_request(ctx, captured)
else:
await ctx.send_empty(404)
finally:
for hook in reversed(self._after_request_hooks):
await hook(ctx)
async def handle_websocket(self, ws: WebSocket) -> None: async def handle_websocket(self, ws: WebSocket) -> None:
result = self._tree.get_handler(ws.path, HttpMethod.WS) for hook in self._before_websocket_hooks:
if result is not None: new_ws = await hook(ws)
handler, captured = result if new_ws is not None:
await handler.handle_request(ws, captured) ws = new_ws
else: try:
await ws.close(1000) result = self._tree.get_handler(ws.path, HttpMethod.WS)
if result is not None:
handler, captured = result
await handler.handle_request(ws, captured)
else:
await ws.close(1000)
finally:
for hook in reversed(self._after_websocket_hooks):
await hook(ws)
def setup(self, loop: AbstractEventLoop) -> None:
for mixin in self._mixins:
mixin.setup(loop)
def shutdown(self, loop: AbstractEventLoop) -> None:
for mixin in self._mixins:
mixin.shutdown(loop)
def route(self, def route(self,
paths: StrOrStrings, paths: StrOrStrings,
@@ -0,0 +1,37 @@
from abc import ABC, abstractmethod
from asyncio import AbstractEventLoop
from typing import TYPE_CHECKING, Sequence
if TYPE_CHECKING:
from ._app import KayaApp
class KayaMixin(ABC):
"""Base class for composable Kaya app extensions.
A mixin modifies a ``KayaApp`` instance in place by registering routes,
adding request/websocket hooks, or exposing helper methods on the mixin
instance itself. Because the app remains a ``KayaApp``, both ASGI and RSGI
protocols keep working regardless of which mixins are applied.
Mixins may declare other mixins they depend on via ``dependencies``. The
``KayaApp`` constructor applies dependencies first and guarantees each
mixin is applied at most once.
"""
@property
def dependencies(self) -> Sequence['KayaMixin']:
return ()
@abstractmethod
def apply(self, app: 'KayaApp') -> None:
"""Configure the app: register routes, add hooks, etc."""
pass
def setup(self, loop: AbstractEventLoop) -> None:
"""Called on lifespan startup (default: no-op)."""
pass
def shutdown(self, loop: AbstractEventLoop) -> None:
"""Called on lifespan shutdown (default: no-op)."""
pass
+13 -10
View File
@@ -10,34 +10,36 @@ Flow with PKCE**.
```python ```python
import os import os
from kaya.core import HttpContext, KayaApp from kaya.core import HttpContext, KayaApp
from kaya.session import SessionMiddleware, InMemorySessionStore from kaya.session import SessionMixin, InMemorySessionStore
from kaya.oidc import OIDCConfig, OIDCApp from kaya.oidc import OIDCConfig, OIDCMixin
app = KayaApp() session = SessionMixin(InMemorySessionStore())
session_app = SessionMiddleware(app, InMemorySessionStore()) oidc = OIDCMixin(
oidc = OIDCApp(
session_app,
OIDCConfig( OIDCConfig(
issuer=os.environ['OIDC_ISSUER'], issuer=os.environ['OIDC_ISSUER'],
client_id=os.environ['OIDC_CLIENT_ID'], client_id=os.environ['OIDC_CLIENT_ID'],
client_secret=os.environ.get('OIDC_CLIENT_SECRET'), client_secret=os.environ.get('OIDC_CLIENT_SECRET'),
redirect_uri='http://localhost:8000/auth/callback', redirect_uri='http://localhost:8000/auth/callback',
fetch_userinfo=True, fetch_userinfo=True,
) ),
session=session,
) )
app = KayaApp(mixins=[session, oidc])
@oidc.GET('/') @app.GET('/')
async def home(ctx: HttpContext): async def home(ctx: HttpContext):
await ctx.send_str(200, 'public home') await ctx.send_str(200, 'public home')
@oidc.GET('/profile') @app.GET('/profile')
@oidc.require_auth @oidc.require_auth
async def profile(ctx: HttpContext): async def profile(ctx: HttpContext):
user = oidc.get_user(ctx) user = oidc.get_user(ctx)
await ctx.send_str(200, f'Hello {user.email or user.sub}') await ctx.send_str(200, f'Hello {user.email or user.sub}')
``` ```
`OIDCMixin` depends on `SessionMixin`; passing only `oidc` to `KayaApp(mixins=...)`
also works because the app applies mixin dependencies automatically.
## Features ## Features
- Generic OIDC discovery - Generic OIDC discovery
@@ -48,6 +50,7 @@ async def profile(ctx: HttpContext):
- Optional userinfo endpoint fetch - Optional userinfo endpoint fetch
- Refresh token support - Refresh token support
- RP-initiated logout (when provider advertises `end_session_endpoint`) - RP-initiated logout (when provider advertises `end_session_endpoint`)
- Composable with any other `KayaMixin` (RSGI, MCP, etc.)
## Security notes ## Security notes
+2 -2
View File
@@ -1,11 +1,11 @@
from ._app import OIDCApp, OIDCUser
from ._client import OIDCClient from ._client import OIDCClient
from ._config import OIDCConfig from ._config import OIDCConfig
from ._mixin import OIDCMixin, OIDCUser
__all__ = [ __all__ = [
'OIDCApp',
'OIDCClient', 'OIDCClient',
'OIDCConfig', 'OIDCConfig',
'OIDCMixin',
'OIDCUser', 'OIDCUser',
] ]
@@ -1,21 +1,14 @@
from typing import Any, Awaitable, Callable, Mapping, MutableMapping, Optional, Sequence, cast from typing import Any, Awaitable, Callable, Mapping, Optional, Sequence
from urllib.parse import parse_qs, urlencode
from kaya.core import HttpContext, HttpMethod, KayaApp from kaya.core import HttpContext, KayaApp, KayaMixin
from kaya.session import Session, SessionMiddleware from kaya.session import Session, SessionMixin
from urllib.parse import parse_qs
from ._client import OIDCClient from ._client import OIDCClient
from ._config import OIDCConfig from ._config import OIDCConfig
type HttpHandler = Callable[..., Awaitable[None]] type HttpHandler = Callable[..., Awaitable[None]]
type WebSocketHandler = Callable[..., Awaitable[None]]
type RouteDecorator = Callable[[HttpHandler], HttpHandler]
type WebSocketDecorator = Callable[[WebSocketHandler], WebSocketHandler]
type ASGIApp = Callable[
[MutableMapping[str, Any], Callable[[], Awaitable[Any]], Callable[[MutableMapping[str, Any]], Awaitable[None]]],
Awaitable[None],
]
class OIDCUser(Mapping[str, Any]): class OIDCUser(Mapping[str, Any]):
@@ -50,42 +43,52 @@ class OIDCUser(Mapping[str, Any]):
return self._data.get('picture') return self._data.get('picture')
class OIDCApp: class OIDCMixin(KayaMixin):
"""ASGI app wrapper that adds OIDC authentication routes to a Kaya app. """Kaya mixin adding OpenID Connect authentication.
The wrapped app must be a ``SessionMiddleware`` instance so that OIDC state, Depends on :class:`~kaya.session.SessionMixin` so that OIDC state, nonce,
nonce, and user data can be stored in ``ctx.session``. and user data can be stored in ``ctx.session``. The dependency is applied
automatically by ``KayaApp``.
Built-in routes: Registers three routes on the app:
- ``login_path`` (default ``/auth/login``): redirects to the OIDC provider. - ``login_path`` (default ``/auth/login``): redirects to the OIDC provider.
- ``callback_path`` (default ``/auth/callback``): handles the provider callback. - ``callback_path`` (default ``/auth/callback``): handles the provider callback.
- ``logout_path`` (default ``/auth/logout``): logs the user out. - ``logout_path`` (default ``/auth/logout``): logs the user out.
Example::
session = SessionMixin(InMemorySessionStore())
oidc = OIDCMixin(config, session=session)
app = KayaApp(mixins=[session, oidc])
@app.GET('/profile')
@oidc.require_auth
async def profile(ctx: HttpContext):
user = oidc.get_user(ctx)
...
""" """
def __init__(self, app: SessionMiddleware, config: OIDCConfig) -> None: def __init__(self, config: OIDCConfig, session: SessionMixin) -> None:
self._app = app
self._config = config self._config = config
self._session = session
self._client = OIDCClient(config) self._client = OIDCClient(config)
self._register_routes()
@staticmethod @property
def _session(ctx: HttpContext) -> Session: def dependencies(self) -> Sequence[KayaMixin]:
session = ctx.session return [self._session]
assert isinstance(session, Session)
return session
def _register_routes(self) -> None: def apply(self, app: KayaApp) -> None:
@self._app.GET(self._config.login_path) @app.GET(self._config.login_path)
async def login(ctx: HttpContext) -> None: async def login(ctx: HttpContext) -> None:
auth_url, state, nonce, code_verifier = await self._client.build_authorization_url() auth_url, state, nonce, code_verifier = await self._client.build_authorization_url()
session = self._session(ctx) session = self._session_of(ctx)
session['oidc_state'] = state session['oidc_state'] = state
session['oidc_nonce'] = nonce session['oidc_nonce'] = nonce
session['oidc_code_verifier'] = code_verifier session['oidc_code_verifier'] = code_verifier
await ctx.send_empty(302, {'Location': auth_url}) await ctx.send_empty(302, {'Location': auth_url})
@self._app.GET(self._config.callback_path) @app.GET(self._config.callback_path)
async def callback(ctx: HttpContext) -> None: async def callback(ctx: HttpContext) -> None:
query = parse_qs(ctx.query_string) query = parse_qs(ctx.query_string)
code = self._first_value(query.get('code')) code = self._first_value(query.get('code'))
@@ -104,7 +107,7 @@ class OIDCApp:
await ctx.send_str(400, 'Missing code or state') await ctx.send_str(400, 'Missing code or state')
return return
session = self._session(ctx) session = self._session_of(ctx)
expected_state = session.get('oidc_state') expected_state = session.get('oidc_state')
if state != expected_state: if state != expected_state:
await ctx.send_str(400, 'Invalid state') await ctx.send_str(400, 'Invalid state')
@@ -144,66 +147,32 @@ class OIDCApp:
except ValueError as exc: except ValueError as exc:
await ctx.send_str(400, f'Authentication failed: {exc}') await ctx.send_str(400, f'Authentication failed: {exc}')
@self._app.GET(self._config.logout_path) @app.GET(self._config.logout_path)
async def logout(ctx: HttpContext) -> None: async def logout(ctx: HttpContext) -> None:
session = self._session(ctx) session = self._session_of(ctx)
id_token = session.get('oidc_id_token') id_token = session.get('oidc_id_token')
session.invalidate() session.invalidate()
logout_url = await self._client.build_logout_url(id_token if isinstance(id_token, str) else None) logout_url = await self._client.build_logout_url(id_token if isinstance(id_token, str) else None)
location = logout_url if logout_url is not None else self._config.post_logout_redirect location = logout_url if logout_url is not None else self._config.post_logout_redirect
await ctx.send_empty(302, {'Location': location}) await ctx.send_empty(302, {'Location': location})
@staticmethod
def _session_of(ctx: HttpContext) -> Session:
session = ctx.session
assert isinstance(session, Session)
return session
@staticmethod @staticmethod
def _first_value(values: Optional[Sequence[str]]) -> Optional[str]: def _first_value(values: Optional[Sequence[str]]) -> Optional[str]:
if values and len(values) > 0: if values and len(values) > 0:
return values[0] return values[0]
return None return None
def route(
self,
paths: str | Sequence[str],
methods: Optional[HttpMethod | Sequence[HttpMethod]] = None,
recursive: bool = False,
) -> RouteDecorator:
return self._app.route(paths, methods, recursive)
def GET(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.GET(path, recursive)
def POST(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.POST(path, recursive)
def PUT(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.PUT(path, recursive)
def DELETE(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.DELETE(path, recursive)
def OPTIONS(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.OPTIONS(path, recursive)
def HEAD(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.HEAD(path, recursive)
def PATCH(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.PATCH(path, recursive)
def websocket(self, path: str, recursive: bool = False) -> WebSocketDecorator:
return self._app.websocket(path, recursive)
async def __call__(
self,
scope: MutableMapping[str, Any],
receive: Callable[[], Awaitable[Any]],
send: Callable[[MutableMapping[str, Any]], Awaitable[None]],
) -> None:
await cast(ASGIApp, self._app)(scope, receive, send)
def is_authenticated(self, ctx: HttpContext) -> bool: def is_authenticated(self, ctx: HttpContext) -> bool:
return 'oidc_user' in self._session(ctx) return 'oidc_user' in self._session_of(ctx)
def get_user(self, ctx: HttpContext) -> Optional[OIDCUser]: def get_user(self, ctx: HttpContext) -> Optional[OIDCUser]:
user = self._session(ctx).get('oidc_user') user = self._session_of(ctx).get('oidc_user')
if user is None or not isinstance(user, Mapping): if user is None or not isinstance(user, Mapping):
return None return None
return OIDCUser(user) return OIDCUser(user)
+63 -37
View File
@@ -1,19 +1,17 @@
import base64 import base64
import json
import unittest import unittest
from time import time from time import time
from typing import Any, Mapping from typing import Mapping
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
import httpx import httpx
import jwt import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from pwo import async_test from pwo import async_test
from kaya.core import HttpContext, KayaApp from kaya.core import HttpContext, KayaApp
from kaya.session import InMemorySessionStore, SessionMiddleware from kaya.session import InMemorySessionStore, SessionMixin
from kaya.oidc import OIDCApp, OIDCConfig from kaya.oidc import OIDCConfig, OIDCMixin
from kaya.oidc._client import OIDCClient from kaya.oidc._client import OIDCClient
@@ -197,14 +195,13 @@ class OIDCClientTest(unittest.TestCase):
self.assertEqual(['/'], query['post_logout_redirect_uri']) self.assertEqual(['/'], query['post_logout_redirect_uri'])
class OIDCAppTest(unittest.TestCase): class OIDCMixinTest(unittest.TestCase):
def _build_app(self, fetch_userinfo: bool = False) -> tuple[OIDCApp, MockOIDCProvider, InMemorySessionStore]: def _build_app(self, fetch_userinfo: bool = False) -> tuple[KayaApp, OIDCMixin, MockOIDCProvider, InMemorySessionStore]:
provider = MockOIDCProvider() provider = MockOIDCProvider()
http_client = httpx.AsyncClient(transport=MockTransport(provider)) http_client = httpx.AsyncClient(transport=MockTransport(provider))
store = InMemorySessionStore() store = InMemorySessionStore()
app = KayaApp() session = SessionMixin(store)
session_app = SessionMiddleware(app, store)
config = OIDCConfig( config = OIDCConfig(
issuer=provider.issuer, issuer=provider.issuer,
client_id='client', client_id='client',
@@ -213,34 +210,35 @@ class OIDCAppTest(unittest.TestCase):
http_client=http_client, http_client=http_client,
fetch_userinfo=fetch_userinfo, fetch_userinfo=fetch_userinfo,
) )
oidc_app = OIDCApp(session_app, config) oidc = OIDCMixin(config, session=session)
return oidc_app, provider, store app = KayaApp(mixins=[session, oidc])
return app, oidc, provider, store
def _setup_routes(self, oidc_app: OIDCApp) -> None: def _setup_routes(self, app: KayaApp, oidc: OIDCMixin) -> None:
@oidc_app.GET('/') @app.GET('/')
async def home(ctx: HttpContext) -> None: async def home(ctx: HttpContext) -> None:
await ctx.send_str(200, 'home') await ctx.send_str(200, 'home')
@oidc_app.GET('/profile') @app.GET('/profile')
@oidc_app.require_auth @oidc.require_auth
async def profile(ctx: HttpContext) -> None: async def profile(ctx: HttpContext) -> None:
user = oidc_app.get_user(ctx) user = oidc.get_user(ctx)
if user is None: if user is None:
await ctx.send_empty(401) await ctx.send_empty(401)
return return
await ctx.send_str(200, f'Hello {user.email}') await ctx.send_str(200, f'Hello {user.email}')
@oidc_app.GET('/refresh') @app.GET('/refresh')
@oidc_app.require_auth @oidc.require_auth
async def refresh(ctx: HttpContext) -> None: async def refresh(ctx: HttpContext) -> None:
new_token = await oidc_app.refresh_access_token(ctx.session) new_token = await oidc.refresh_access_token(ctx.session)
await ctx.send_str(200, new_token or 'no-token') await ctx.send_str(200, new_token or 'no-token')
@async_test @async_test
async def test_login_redirect(self) -> None: async def test_login_redirect(self) -> None:
oidc_app, provider, store = self._build_app() app, oidc, provider, store = self._build_app()
self._setup_routes(oidc_app) self._setup_routes(app, oidc)
transport = httpx.ASGITransport(app=oidc_app) transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/auth/login', follow_redirects=False) r = await client.get('/auth/login', follow_redirects=False)
self.assertEqual(302, r.status_code) self.assertEqual(302, r.status_code)
@@ -250,9 +248,9 @@ class OIDCAppTest(unittest.TestCase):
@async_test @async_test
async def test_callback_success(self) -> None: async def test_callback_success(self) -> None:
oidc_app, provider, store = self._build_app(fetch_userinfo=True) app, oidc, provider, store = self._build_app(fetch_userinfo=True)
self._setup_routes(oidc_app) self._setup_routes(app, oidc)
transport = httpx.ASGITransport(app=oidc_app) transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/auth/login', follow_redirects=False) r = await client.get('/auth/login', follow_redirects=False)
self.assertEqual(302, r.status_code) self.assertEqual(302, r.status_code)
@@ -274,18 +272,18 @@ class OIDCAppTest(unittest.TestCase):
@async_test @async_test
async def test_callback_invalid_state(self) -> None: async def test_callback_invalid_state(self) -> None:
oidc_app, provider, store = self._build_app() app, oidc, provider, store = self._build_app()
self._setup_routes(oidc_app) self._setup_routes(app, oidc)
transport = httpx.ASGITransport(app=oidc_app) transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/auth/callback', params={'code': 'mock-code', 'state': 'wrong'}, follow_redirects=False) r = await client.get('/auth/callback', params={'code': 'mock-code', 'state': 'wrong'}, follow_redirects=False)
self.assertEqual(400, r.status_code) self.assertEqual(400, r.status_code)
@async_test @async_test
async def test_logout(self) -> None: async def test_logout(self) -> None:
oidc_app, provider, store = self._build_app() app, oidc, provider, store = self._build_app()
self._setup_routes(oidc_app) self._setup_routes(app, oidc)
transport = httpx.ASGITransport(app=oidc_app) transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/auth/login', follow_redirects=False) r = await client.get('/auth/login', follow_redirects=False)
parsed = urlparse(r.headers['Location']) parsed = urlparse(r.headers['Location'])
@@ -304,9 +302,9 @@ class OIDCAppTest(unittest.TestCase):
@async_test @async_test
async def test_require_auth_redirect(self) -> None: async def test_require_auth_redirect(self) -> None:
oidc_app, provider, store = self._build_app() app, oidc, provider, store = self._build_app()
self._setup_routes(oidc_app) self._setup_routes(app, oidc)
transport = httpx.ASGITransport(app=oidc_app) transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/profile', follow_redirects=False) r = await client.get('/profile', follow_redirects=False)
self.assertEqual(302, r.status_code) self.assertEqual(302, r.status_code)
@@ -314,9 +312,9 @@ class OIDCAppTest(unittest.TestCase):
@async_test @async_test
async def test_refresh_access_token(self) -> None: async def test_refresh_access_token(self) -> None:
oidc_app, provider, store = self._build_app() app, oidc, provider, store = self._build_app()
self._setup_routes(oidc_app) self._setup_routes(app, oidc)
transport = httpx.ASGITransport(app=oidc_app) transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/auth/login', follow_redirects=False) r = await client.get('/auth/login', follow_redirects=False)
parsed = urlparse(r.headers['Location']) parsed = urlparse(r.headers['Location'])
@@ -329,6 +327,34 @@ class OIDCAppTest(unittest.TestCase):
self.assertEqual(200, r.status_code) self.assertEqual(200, r.status_code)
self.assertEqual('new-access-token', r.text) self.assertEqual('new-access-token', r.text)
@async_test
async def test_dependency_applied_automatically(self) -> None:
# Only pass oidc to KayaApp; SessionMixin should be applied via dependencies.
provider = MockOIDCProvider()
http_client = httpx.AsyncClient(transport=MockTransport(provider))
store = InMemorySessionStore()
session = SessionMixin(store)
config = OIDCConfig(
issuer=provider.issuer,
client_id='client',
client_secret='secret',
redirect_uri='http://localhost:8000/auth/callback',
http_client=http_client,
)
oidc = OIDCMixin(config, session=session)
app = KayaApp(mixins=[oidc])
@app.GET('/')
async def home(ctx: HttpContext) -> None:
ctx.session['x'] = 1
await ctx.send_str(200, 'ok')
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/')
self.assertEqual(200, r.status_code)
self.assertIn('Set-Cookie', r.headers)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+10 -9
View File
@@ -9,12 +9,12 @@ session data is accessible from request handlers as `ctx.session`.
```python ```python
from kaya.core import KayaApp, HttpContext from kaya.core import KayaApp, HttpContext
from kaya.session import SessionMiddleware, InMemorySessionStore from kaya.session import SessionMixin, InMemorySessionStore
app = KayaApp() session = SessionMixin(InMemorySessionStore())
session_app = SessionMiddleware(app, InMemorySessionStore()) app = KayaApp(mixins=[session])
@session_app.GET('/') @app.GET('/')
async def home(ctx: HttpContext): async def home(ctx: HttpContext):
n = ctx.session.get('visits', 0) + 1 n = ctx.session.get('visits', 0) + 1
ctx.session['visits'] = n ctx.session['visits'] = n
@@ -24,11 +24,14 @@ async def home(ctx: HttpContext):
Sessions are created lazily: a cookie is only set when the handler modifies the Sessions are created lazily: a cookie is only set when the handler modifies the
session. session.
`SessionMixin` is a `KayaMixin`, so the app stays a `KayaApp` and both ASGI and
RSGI keep working.
## Session expiry ## Session expiry
The cookie sent to the browser has a `Max-Age` (default 14 days), but that is The cookie sent to the browser has a `Max-Age` (default 14 days), but that is
only a client-side hint. The real boundary is the store's server-side TTL, only a client-side hint. The real boundary is the store's server-side TTL,
which the middleware keeps in sync with the cookie `Max-Age`. which the mixin keeps in sync with the cookie `Max-Age`.
For `InMemorySessionStore`, a session expires if it is idle for longer than For `InMemorySessionStore`, a session expires if it is idle for longer than
`max_age`. Active sessions have their expiry slid forward on every access, so `max_age`. Active sessions have their expiry slid forward on every access, so
@@ -44,14 +47,12 @@ attribute) entirely.
- `Session`: dict-like session object with modification tracking - `Session`: dict-like session object with modification tracking
- `SessionStore`: abstract store interface - `SessionStore`: abstract store interface
- `InMemorySessionStore`: simple in-memory store for development/single-process - `InMemorySessionStore`: simple in-memory store for development/single-process
- `SessionMiddleware`: ASGI middleware managing session cookies and persistence - `SessionMixin`: composable Kaya mixin managing session cookies and persistence
- Session ID regeneration (`session.regenerate_id()`) and invalidation - Session ID regeneration (`session.regenerate_id()`) and invalidation
(`session.invalidate()`) for future authentication layers (`session.invalidate()`) for authentication layers
## Notes ## Notes
- This release supports HTTP requests only; WebSocket and RSGI propagation is
planned for future releases.
- `InMemorySessionStore` does not survive process restarts and is not shared - `InMemorySessionStore` does not survive process restarts and is not shared
across processes. Production deployments should use a store backed by a shared across processes. Production deployments should use a store backed by a shared
storage system (planned). storage system (planned).
@@ -1,4 +1,4 @@
from ._middleware import SessionMiddleware from ._mixin import SessionMixin
from ._session import Session from ._session import Session
from ._store import InMemorySessionStore, SessionStore from ._store import InMemorySessionStore, SessionStore
@@ -6,6 +6,6 @@ from ._store import InMemorySessionStore, SessionStore
__all__ = [ __all__ = [
'InMemorySessionStore', 'InMemorySessionStore',
'Session', 'Session',
'SessionMiddleware', 'SessionMixin',
'SessionStore', 'SessionStore',
] ]
@@ -1,178 +0,0 @@
from typing import Any, Awaitable, Callable, MutableMapping, Optional, Sequence, cast
from kaya.core import HttpMethod, KayaApp
from ._cookie import format_set_cookie, parse_cookie_value
from ._session import Session
from ._store import SessionStore
type HttpHandler = Callable[..., Awaitable[None]]
type WebSocketHandler = Callable[..., Awaitable[None]]
type RouteDecorator = Callable[[HttpHandler], HttpHandler]
type WebSocketDecorator = Callable[[WebSocketHandler], WebSocketHandler]
type ASGIApp = Callable[
[MutableMapping[str, Any], Callable[[], Awaitable[Any]], Callable[[MutableMapping[str, Any]], Awaitable[None]]],
Awaitable[None],
]
class SessionMiddleware:
"""ASGI middleware providing server-side HTTP sessions.
The middleware reads a session cookie from each HTTP request, loads the
session data via the configured store, and makes the session available to
Kaya handlers as ``ctx.session``. After the request it persists the session
and refreshes the cookie.
Routing methods are delegated to the wrapped ``KayaApp`` so the middleware
can be used as a drop-in replacement when registering handlers.
Example::
app = KayaApp()
session_app = SessionMiddleware(app, InMemorySessionStore())
@session_app.GET('/')
async def home(ctx: HttpContext):
ctx.session['visits'] = ctx.session.get('visits', 0) + 1
await ctx.send_str(200, f"visits: {ctx.session['visits']}")
"""
def __init__(
self,
app: KayaApp,
store: SessionStore,
cookie_name: str = 'session_id',
path: str = '/',
max_age: Optional[int] = 14 * 24 * 60 * 60,
httponly: bool = True,
secure: bool = False,
samesite: Optional[str] = 'Lax',
) -> None:
self._app = app
self._store = store
self._cookie_name = cookie_name
self._path = path
self._max_age = max_age
self._httponly = httponly
self._secure = secure
self._samesite = samesite
def route(
self,
paths: str | Sequence[str],
methods: Optional[HttpMethod | Sequence[HttpMethod]] = None,
recursive: bool = False,
) -> RouteDecorator:
return self._app.route(paths, methods, recursive)
def GET(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.GET(path, recursive)
def POST(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.POST(path, recursive)
def PUT(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.PUT(path, recursive)
def DELETE(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.DELETE(path, recursive)
def OPTIONS(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.OPTIONS(path, recursive)
def HEAD(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.HEAD(path, recursive)
def PATCH(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.PATCH(path, recursive)
def websocket(self, path: str, recursive: bool = False) -> WebSocketDecorator:
return self._app.websocket(path, recursive)
async def __call__(
self,
scope: MutableMapping[str, Any],
receive: Callable[[], Awaitable[Any]],
send: Callable[[MutableMapping[str, Any]], Awaitable[None]],
) -> None:
if scope['type'] != 'http':
await cast(ASGIApp, self._app)(scope, receive, send)
return
session_id = self._extract_session_id(scope)
session: Session
if session_id is not None:
loaded = await self._store.load(session_id, self._max_age)
session = loaded if loaded is not None else Session()
else:
session = Session()
state = scope.get('state')
if not isinstance(state, dict):
state = {}
scope['state'] = state
state['kaya_session'] = session
async def wrapped_send(message: MutableMapping[str, Any]) -> None:
if message['type'] == 'http.response.start':
message = dict(message)
final_session_id = self._finalize_session_id(session)
if final_session_id is not None:
cookie_value = format_set_cookie(
self._cookie_name,
final_session_id,
path=self._path,
max_age=0 if session.invalidated else self._max_age,
httponly=self._httponly,
secure=self._secure,
samesite=self._samesite,
)
headers = list(message.get('headers', []))
headers.append((b'Set-Cookie', cookie_value.encode()))
message['headers'] = headers
await send(message)
try:
await cast(ASGIApp, self._app)(scope, receive, wrapped_send)
finally:
await self._persist(session)
def _extract_session_id(self, scope: MutableMapping[str, Any]) -> Optional[str]:
headers = scope.get('headers', [])
for key, value in headers:
key_bytes: bytes = key if isinstance(key, bytes) else key.encode()
if key_bytes.lower() == b'cookie':
value_str: str = value.decode() if isinstance(value, bytes) else value
return parse_cookie_value(value_str, self._cookie_name)
return None
def _finalize_session_id(self, session: Session) -> Optional[str]:
if session.invalidated:
return session.id
if session.id is None:
if session.modified or session.regenerate:
session.set_id(self._store.new_session_id())
elif session.regenerate:
session._old_id = session.id
session.set_id(self._store.new_session_id())
session._regenerate = False
return session.id
async def _persist(self, session: Session) -> None:
if session.invalidated:
old_id = session._old_id or session.id
if old_id is not None:
await self._store.delete(old_id)
return
if session._old_id is not None and session._old_id != session.id:
await self._store.delete(session._old_id)
session._old_id = None
if session.id is None and session.modified:
session.set_id(self._store.new_session_id())
if session.id is not None:
await self._store.save(session.id, session, self._max_age)
@@ -0,0 +1,200 @@
from pathlib import Path
from typing import Any, AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping, Optional, Sequence
from kaya.core import HttpContext, KayaApp, KayaMixin
from kaya.core._types import StrOrStrings
from ._cookie import format_set_cookie, parse_cookie_value
from ._session import Session
from ._store import SessionStore
class SessionHttpContext(HttpContext):
"""HttpContext wrapper that exposes ``session`` and injects the session
cookie into response headers.
Works with any concrete ``HttpContext`` (ASGI or RSGI) because it only
relies on the abstract send methods, which all implementations share.
"""
def __init__(
self,
ctx: HttpContext,
session: Session,
cookie_injector: Callable[[], Optional[str]],
) -> None:
self._ctx = ctx
self.session = session
self._cookie_injector = cookie_injector
self.pathsend = ctx.pathsend
self.receive = ctx.receive
self.send = ctx.send
self.scheme = ctx.scheme
self.method = ctx.method
self.path = ctx.path
self.query_string = ctx.query_string
self.headers = ctx.headers
self.client = ctx.client
self.server = ctx.server
self.request_body = ctx.request_body
def _inject_cookie(self, headers: Optional[Mapping[str, StrOrStrings]]) -> Optional[Mapping[str, StrOrStrings]]:
cookie_value = self._cookie_injector()
if cookie_value is None:
return headers
new_headers: dict[str, StrOrStrings] = dict(headers) if headers else {}
existing = new_headers.get('Set-Cookie')
if existing is None:
new_headers['Set-Cookie'] = cookie_value
elif isinstance(existing, str):
new_headers['Set-Cookie'] = (existing, cookie_value)
else:
new_headers['Set-Cookie'] = (*existing, cookie_value)
return new_headers
async def stream_body(self,
status: int,
body_generator: AsyncGenerator[bytes, None],
headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
await self._ctx.stream_body(status, body_generator, self._inject_cookie(headers))
async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
await self._ctx.send_bytes(status, body, self._inject_cookie(headers))
async def send_str(self, status: int, body: str, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
await self._ctx.send_str(status, body, self._inject_cookie(headers))
async def send_file(self, status: int, path: Path, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
await self._ctx.send_file(status, path, self._inject_cookie(headers))
async def send_empty(self, status: int, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
await self._ctx.send_empty(status, self._inject_cookie(headers))
class _CookieInjector:
"""Computes the Set-Cookie value once (on first response) and caches it."""
def __init__(self, mixin: 'SessionMixin', session: Session) -> None:
self._mixin = mixin
self._session = session
self._value: Optional[str] = None
self._computed = False
def __call__(self) -> Optional[str]:
if not self._computed:
self._value = self._mixin._compute_cookie(self._session)
self._computed = True
return self._value
class SessionMixin(KayaMixin):
"""Kaya mixin providing server-side HTTP sessions.
Registers before/after request hooks that load and persist the session and
injects the session cookie into responses via a wrapped ``HttpContext``.
Because the app stays a ``KayaApp``, both ASGI and RSGI keep working.
Example::
session = SessionMixin(InMemorySessionStore())
app = KayaApp(mixins=[session])
@app.GET('/')
async def home(ctx: HttpContext):
ctx.session['visits'] = ctx.session.get('visits', 0) + 1
await ctx.send_str(200, f"visits: {ctx.session['visits']}")
"""
def __init__(
self,
store: SessionStore,
cookie_name: str = 'session_id',
path: str = '/',
max_age: Optional[int] = 14 * 24 * 60 * 60,
httponly: bool = True,
secure: bool = False,
samesite: Optional[str] = 'Lax',
) -> None:
self._store = store
self._cookie_name = cookie_name
self._path = path
self._max_age = max_age
self._httponly = httponly
self._secure = secure
self._samesite = samesite
def apply(self, app: KayaApp) -> None:
app.add_before_request_hook(self._before_request)
app.add_after_request_hook(self._after_request)
async def _before_request(self, ctx: HttpContext) -> Optional[HttpContext]:
session_id = self._extract_session_id(ctx)
session: Session
if session_id is not None:
loaded = await self._store.load(session_id, self._max_age)
session = loaded if loaded is not None else Session()
else:
session = Session()
injector = _CookieInjector(self, session)
return SessionHttpContext(ctx, session, injector)
async def _after_request(self, ctx: HttpContext) -> None:
session = ctx.session
if not isinstance(session, Session):
return
await self._persist(session)
def _extract_session_id(self, ctx: HttpContext) -> Optional[str]:
cookie_header_values = ctx.headers.get('cookie')
if cookie_header_values is None:
return None
if isinstance(cookie_header_values, str):
return parse_cookie_value(cookie_header_values, self._cookie_name)
for value in cookie_header_values:
found = parse_cookie_value(value, self._cookie_name)
if found is not None:
return found
return None
def _compute_cookie(self, session: Session) -> Optional[str]:
final_session_id = self._finalize_session_id(session)
if final_session_id is None:
return None
return format_set_cookie(
self._cookie_name,
final_session_id,
path=self._path,
max_age=0 if session.invalidated else self._max_age,
httponly=self._httponly,
secure=self._secure,
samesite=self._samesite,
)
def _finalize_session_id(self, session: Session) -> Optional[str]:
if session.invalidated:
return session.id
if session.id is None:
if session.modified or session.regenerate:
session.set_id(self._store.new_session_id())
elif session.regenerate:
session._old_id = session.id
session.set_id(self._store.new_session_id())
session._regenerate = False
return session.id
async def _persist(self, session: Session) -> None:
if session.invalidated:
old_id = session._old_id or session.id
if old_id is not None:
await self._store.delete(old_id)
return
if session._old_id is not None and session._old_id != session.id:
await self._store.delete(session._old_id)
session._old_id = None
if session.id is None and session.modified:
session.set_id(self._store.new_session_id())
if session.id is not None:
await self._store.save(session.id, session, self._max_age)
+19 -22
View File
@@ -6,7 +6,7 @@ import httpx
from pwo import async_test from pwo import async_test
from kaya.core import KayaApp, HttpContext from kaya.core import KayaApp, HttpContext
from kaya.session import InMemorySessionStore, Session, SessionMiddleware, SessionStore from kaya.session import InMemorySessionStore, Session, SessionMixin, SessionStore
from kaya.session._cookie import format_set_cookie, parse_cookie_value from kaya.session._cookie import format_set_cookie, parse_cookie_value
@@ -24,42 +24,40 @@ class FakeClock:
class SessionTest(unittest.TestCase): class SessionTest(unittest.TestCase):
app: KayaApp app: KayaApp
store: InMemorySessionStore store: InMemorySessionStore
session_app: SessionMiddleware
def setUp(self) -> None: def setUp(self) -> None:
self.app = KayaApp()
self.store = InMemorySessionStore() self.store = InMemorySessionStore()
self.session_app = SessionMiddleware(self.app, self.store) self.app = KayaApp(mixins=[SessionMixin(self.store)])
@self.session_app.GET('/') @self.app.GET('/')
async def home(ctx: HttpContext) -> None: async def home(ctx: HttpContext) -> None:
n = ctx.session.get('visits', 0) + 1 n = ctx.session.get('visits', 0) + 1
ctx.session['visits'] = n ctx.session['visits'] = n
await ctx.send_str(200, f'visits: {n}') await ctx.send_str(200, f'visits: {n}')
@self.session_app.GET('/read') @self.app.GET('/read')
async def read(ctx: HttpContext) -> None: async def read(ctx: HttpContext) -> None:
n = ctx.session.get('visits', 0) n = ctx.session.get('visits', 0)
await ctx.send_str(200, f'visits: {n}') await ctx.send_str(200, f'visits: {n}')
@self.session_app.GET('/write') @self.app.GET('/write')
async def write(ctx: HttpContext) -> None: async def write(ctx: HttpContext) -> None:
ctx.session['foo'] = 'bar' ctx.session['foo'] = 'bar'
await ctx.send_str(200, 'ok') await ctx.send_str(200, 'ok')
@self.session_app.GET('/clear') @self.app.GET('/clear')
async def clear(ctx: HttpContext) -> None: async def clear(ctx: HttpContext) -> None:
ctx.session.invalidate() ctx.session.invalidate()
await ctx.send_str(200, 'cleared') await ctx.send_str(200, 'cleared')
@self.session_app.GET('/rotate') @self.app.GET('/rotate')
async def rotate(ctx: HttpContext) -> None: async def rotate(ctx: HttpContext) -> None:
ctx.session.regenerate_id() ctx.session.regenerate_id()
await ctx.send_str(200, 'rotated') await ctx.send_str(200, 'rotated')
@async_test @async_test
async def test_session_persists_across_requests(self) -> None: async def test_session_persists_across_requests(self) -> None:
transport = httpx.ASGITransport(app=self.session_app) transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/') r = await client.get('/')
self.assertEqual(200, r.status_code) self.assertEqual(200, r.status_code)
@@ -72,7 +70,7 @@ class SessionTest(unittest.TestCase):
@async_test @async_test
async def test_no_cookie_when_session_not_modified(self) -> None: async def test_no_cookie_when_session_not_modified(self) -> None:
transport = httpx.ASGITransport(app=self.session_app) transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/read') r = await client.get('/read')
self.assertEqual(200, r.status_code) self.assertEqual(200, r.status_code)
@@ -81,7 +79,7 @@ class SessionTest(unittest.TestCase):
@async_test @async_test
async def test_existing_session_refreshes_cookie(self) -> None: async def test_existing_session_refreshes_cookie(self) -> None:
transport = httpx.ASGITransport(app=self.session_app) transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await client.get('/') await client.get('/')
r = await client.get('/read') r = await client.get('/read')
@@ -91,7 +89,7 @@ class SessionTest(unittest.TestCase):
@async_test @async_test
async def test_cookie_attributes(self) -> None: async def test_cookie_attributes(self) -> None:
transport = httpx.ASGITransport(app=self.session_app) transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/') r = await client.get('/')
set_cookie = r.headers['Set-Cookie'] set_cookie = r.headers['Set-Cookie']
@@ -102,7 +100,7 @@ class SessionTest(unittest.TestCase):
@async_test @async_test
async def test_sessions_are_isolated(self) -> None: async def test_sessions_are_isolated(self) -> None:
transport = httpx.ASGITransport(app=self.session_app) transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r1 = await client.get('/') r1 = await client.get('/')
r2 = await client.get('/') r2 = await client.get('/')
@@ -115,7 +113,7 @@ class SessionTest(unittest.TestCase):
@async_test @async_test
async def test_invalidate(self) -> None: async def test_invalidate(self) -> None:
transport = httpx.ASGITransport(app=self.session_app) transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await client.get('/') await client.get('/')
r = await client.get('/clear') r = await client.get('/clear')
@@ -129,7 +127,7 @@ class SessionTest(unittest.TestCase):
@async_test @async_test
async def test_regenerate_id(self) -> None: async def test_regenerate_id(self) -> None:
transport = httpx.ASGITransport(app=self.session_app) transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await client.get('/') await client.get('/')
cookies_before = {c.name: c.value for c in client.cookies.jar} cookies_before = {c.name: c.value for c in client.cookies.jar}
@@ -153,7 +151,7 @@ class SessionTest(unittest.TestCase):
@async_test @async_test
async def test_invalid_cookie_creates_fresh_session(self) -> None: async def test_invalid_cookie_creates_fresh_session(self) -> None:
transport = httpx.ASGITransport(app=self.session_app) transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
client.cookies.set('session_id', 'not-a-real-id') client.cookies.set('session_id', 'not-a-real-id')
r = await client.get('/') r = await client.get('/')
@@ -164,19 +162,18 @@ class SessionTest(unittest.TestCase):
async def test_stale_cookie_cannot_access_old_data(self) -> None: async def test_stale_cookie_cannot_access_old_data(self) -> None:
clock = FakeClock() clock = FakeClock()
store = InMemorySessionStore(clock=clock) store = InMemorySessionStore(clock=clock)
app = KayaApp() app = KayaApp(mixins=[SessionMixin(store, max_age=60)])
session_app = SessionMiddleware(app, store, max_age=60)
@session_app.GET('/') @app.GET('/')
async def home(ctx: HttpContext) -> None: async def home(ctx: HttpContext) -> None:
ctx.session['secret'] = 'super-sensitive' ctx.session['secret'] = 'super-sensitive'
await ctx.send_str(200, 'ok') await ctx.send_str(200, 'ok')
@session_app.GET('/read') @app.GET('/read')
async def read(ctx: HttpContext) -> None: async def read(ctx: HttpContext) -> None:
await ctx.send_str(200, ctx.session.get('secret', 'none')) await ctx.send_str(200, ctx.session.get('secret', 'none'))
transport = httpx.ASGITransport(app=session_app) transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/') r = await client.get('/')
self.assertEqual('ok', r.text) self.assertEqual('ok', r.text)