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:
+10
-10
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
+42
-73
@@ -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)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user