kernia-django 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kernia_django-0.1.0/.gitignore +38 -0
- kernia_django-0.1.0/LICENSE +21 -0
- kernia_django-0.1.0/PKG-INFO +72 -0
- kernia_django-0.1.0/README.md +43 -0
- kernia_django-0.1.0/pyproject.toml +56 -0
- kernia_django-0.1.0/src/kernia_django/__init__.py +29 -0
- kernia_django-0.1.0/src/kernia_django/apps.py +24 -0
- kernia_django-0.1.0/src/kernia_django/decorators.py +27 -0
- kernia_django-0.1.0/src/kernia_django/middleware.py +83 -0
- kernia_django-0.1.0/src/kernia_django/py.typed +0 -0
- kernia_django-0.1.0/src/kernia_django/views.py +211 -0
- kernia_django-0.1.0/tests/test_django_unit.py +88 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.venv/
|
|
6
|
+
.uv/
|
|
7
|
+
.mypy_cache/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.coverage
|
|
11
|
+
htmlcov/
|
|
12
|
+
dist/
|
|
13
|
+
build/
|
|
14
|
+
|
|
15
|
+
# Editors
|
|
16
|
+
.idea/
|
|
17
|
+
.vscode/
|
|
18
|
+
*.swp
|
|
19
|
+
.DS_Store
|
|
20
|
+
|
|
21
|
+
# Docs build output
|
|
22
|
+
/site/
|
|
23
|
+
docs/site/
|
|
24
|
+
|
|
25
|
+
# Internal tooling (not part of the public repo)
|
|
26
|
+
scripts/audit_layout.py
|
|
27
|
+
scripts/setup_kernia_dns.sh
|
|
28
|
+
spec/
|
|
29
|
+
|
|
30
|
+
# Local
|
|
31
|
+
|
|
32
|
+
.projects/cache
|
|
33
|
+
.projects/vault
|
|
34
|
+
.projects/state.test.json
|
|
35
|
+
.projects/state.local.test.json
|
|
36
|
+
.env
|
|
37
|
+
.env.local
|
|
38
|
+
.env.test
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Advantch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kernia-django
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Django integration
|
|
5
|
+
Project-URL: Homepage, https://kernia.dev
|
|
6
|
+
Project-URL: Documentation, https://kernia.dev/docs
|
|
7
|
+
Project-URL: Source, https://github.com/advantch/kernia
|
|
8
|
+
Project-URL: Issues, https://github.com/advantch/kernia/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/advantch/kernia/releases
|
|
10
|
+
Author: Advantch
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: asgi,authentication,authorization,django,fastapi,oauth,passkeys,security,sessions,sso,starlette
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Session
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: anyio>=4
|
|
26
|
+
Requires-Dist: django>=4.2
|
|
27
|
+
Requires-Dist: kernia>=0.1.0
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# kernia-django
|
|
31
|
+
|
|
32
|
+
Django integration for Kernia. Mounts the auth router, populates `request.kernia_session` and `request.kernia_user`, and provides a view decorator for protected views.
|
|
33
|
+
|
|
34
|
+
Part of [Kernia](https://kernia.dev), a framework-agnostic authentication library for Python.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
pip install kernia-django
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
Add the app to `INSTALLED_APPS` and the middleware, then splice the auth routes into `urls.py`:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
# urls.py
|
|
46
|
+
from django.urls import path
|
|
47
|
+
from kernia_django import setup
|
|
48
|
+
from myproject.auth import auth # your init(KerniaOptions(...)) instance
|
|
49
|
+
|
|
50
|
+
urlpatterns = [
|
|
51
|
+
*setup(auth, url_prefix="/api/auth"),
|
|
52
|
+
]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
# views.py
|
|
57
|
+
from kernia_django import require_session
|
|
58
|
+
|
|
59
|
+
@require_session
|
|
60
|
+
def me(request):
|
|
61
|
+
return JsonResponse({"user_id": request.kernia_user["id"]})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Django is sync-by-default; the bridge uses `asgiref.sync.async_to_sync` to call the async core.
|
|
65
|
+
|
|
66
|
+
## Documentation
|
|
67
|
+
|
|
68
|
+
Full documentation at [kernia.dev/docs](https://kernia.dev/docs). Source at [github.com/advantch/kernia](https://github.com/advantch/kernia).
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# kernia-django
|
|
2
|
+
|
|
3
|
+
Django integration for Kernia. Mounts the auth router, populates `request.kernia_session` and `request.kernia_user`, and provides a view decorator for protected views.
|
|
4
|
+
|
|
5
|
+
Part of [Kernia](https://kernia.dev), a framework-agnostic authentication library for Python.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
pip install kernia-django
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Add the app to `INSTALLED_APPS` and the middleware, then splice the auth routes into `urls.py`:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
# urls.py
|
|
17
|
+
from django.urls import path
|
|
18
|
+
from kernia_django import setup
|
|
19
|
+
from myproject.auth import auth # your init(KerniaOptions(...)) instance
|
|
20
|
+
|
|
21
|
+
urlpatterns = [
|
|
22
|
+
*setup(auth, url_prefix="/api/auth"),
|
|
23
|
+
]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
# views.py
|
|
28
|
+
from kernia_django import require_session
|
|
29
|
+
|
|
30
|
+
@require_session
|
|
31
|
+
def me(request):
|
|
32
|
+
return JsonResponse({"user_id": request.kernia_user["id"]})
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Django is sync-by-default; the bridge uses `asgiref.sync.async_to_sync` to call the async core.
|
|
36
|
+
|
|
37
|
+
## Documentation
|
|
38
|
+
|
|
39
|
+
Full documentation at [kernia.dev/docs](https://kernia.dev/docs). Source at [github.com/advantch/kernia](https://github.com/advantch/kernia).
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "kernia-django"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Django integration"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"kernia>=0.1.0",
|
|
8
|
+
"django>=4.2",
|
|
9
|
+
"anyio>=4",
|
|
10
|
+
]
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = [
|
|
14
|
+
"LICENSE",
|
|
15
|
+
]
|
|
16
|
+
authors = [
|
|
17
|
+
{name = "Advantch"},
|
|
18
|
+
]
|
|
19
|
+
keywords = [
|
|
20
|
+
"authentication",
|
|
21
|
+
"authorization",
|
|
22
|
+
"sessions",
|
|
23
|
+
"oauth",
|
|
24
|
+
"passkeys",
|
|
25
|
+
"sso",
|
|
26
|
+
"asgi",
|
|
27
|
+
"fastapi",
|
|
28
|
+
"starlette",
|
|
29
|
+
"django",
|
|
30
|
+
"security",
|
|
31
|
+
]
|
|
32
|
+
classifiers = [
|
|
33
|
+
"Development Status :: 4 - Beta",
|
|
34
|
+
"Intended Audience :: Developers",
|
|
35
|
+
"Operating System :: OS Independent",
|
|
36
|
+
"Programming Language :: Python :: 3",
|
|
37
|
+
"Programming Language :: Python :: 3.11",
|
|
38
|
+
"Programming Language :: Python :: 3.12",
|
|
39
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
40
|
+
"Topic :: Internet :: WWW/HTTP :: Session",
|
|
41
|
+
"Topic :: Security",
|
|
42
|
+
"Typing :: Typed",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://kernia.dev"
|
|
47
|
+
Documentation = "https://kernia.dev/docs"
|
|
48
|
+
Source = "https://github.com/advantch/kernia"
|
|
49
|
+
Issues = "https://github.com/advantch/kernia/issues"
|
|
50
|
+
Changelog = "https://github.com/advantch/kernia/releases"
|
|
51
|
+
[build-system]
|
|
52
|
+
requires = ["hatchling"]
|
|
53
|
+
build-backend = "hatchling.build"
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel]
|
|
56
|
+
packages = ["src/kernia_django"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Django integration for Kernia.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
* :class:`apps.KerniaConfig` — Django app config (add to ``INSTALLED_APPS``).
|
|
6
|
+
* :class:`middleware.KerniaMiddleware` — populates
|
|
7
|
+
``request.kernia_session`` / ``request.kernia_user``.
|
|
8
|
+
* :func:`decorators.require_session` — 401-JSON view decorator.
|
|
9
|
+
* :class:`views.KerniaView` — class-based view that forwards to the
|
|
10
|
+
Kernia ASGI router.
|
|
11
|
+
* :func:`setup` — convenience that returns urlpatterns for the auth router.
|
|
12
|
+
|
|
13
|
+
Django is sync-by-default; the bridge uses ``asgiref.sync.async_to_sync`` to
|
|
14
|
+
call the async core. Each Kernia request pays one thread hop.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from kernia_django.decorators import require_session
|
|
18
|
+
from kernia_django.middleware import KerniaMiddleware
|
|
19
|
+
from kernia_django.views import KerniaView, setup
|
|
20
|
+
|
|
21
|
+
default_app_config = "kernia_django.apps.KerniaConfig"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"KerniaMiddleware",
|
|
25
|
+
"KerniaView",
|
|
26
|
+
"default_app_config",
|
|
27
|
+
"require_session",
|
|
28
|
+
"setup",
|
|
29
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Django app config for kernia-django.
|
|
2
|
+
|
|
3
|
+
Importing this module triggers no Django machinery; the config is only loaded
|
|
4
|
+
once the user adds ``kernia_django`` to ``INSTALLED_APPS``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from django.apps import AppConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class KerniaConfig(AppConfig):
|
|
13
|
+
"""Standard Django app config — no ready-hook side effects.
|
|
14
|
+
|
|
15
|
+
The auth instance lives on whatever the user passes into ``setup()`` /
|
|
16
|
+
``KerniaView.as_view(auth=...)``. We deliberately don't read it from
|
|
17
|
+
settings here so users can construct the ``Kernia`` object at module
|
|
18
|
+
load time without forcing a Django settings dependency on the core.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
name = "kernia_django"
|
|
22
|
+
label = "kernia_django"
|
|
23
|
+
verbose_name = "Better Auth"
|
|
24
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""View decorators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from django.http import HttpRequest, JsonResponse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def require_session(view_func: Callable[..., Any]) -> Callable[..., Any]:
|
|
13
|
+
"""Return 401 JSON if ``request.kernia_session`` is not set.
|
|
14
|
+
|
|
15
|
+
Relies on :class:`KerniaMiddleware` having already populated the
|
|
16
|
+
attribute. Without the middleware in ``MIDDLEWARE`` the attribute will not
|
|
17
|
+
exist; we treat that as unauthenticated as well (fail-closed).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@wraps(view_func)
|
|
21
|
+
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
|
|
22
|
+
session = getattr(request, "kernia_session", None)
|
|
23
|
+
if session is None:
|
|
24
|
+
return JsonResponse({"error": "UNAUTHORIZED"}, status=401)
|
|
25
|
+
return view_func(request, *args, **kwargs)
|
|
26
|
+
|
|
27
|
+
return wrapper
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Django middleware that hydrates ``request.kernia_session``.
|
|
2
|
+
|
|
3
|
+
The middleware reads the session cookie, resolves it through the Kernia
|
|
4
|
+
core, and attaches the result (which may be ``None``) under a dedicated
|
|
5
|
+
namespace so Django's own auth/session stack remains untouched.
|
|
6
|
+
|
|
7
|
+
Because Django middleware runs sync by default, we cross the async boundary
|
|
8
|
+
with :func:`asgiref.sync.async_to_sync`. That is a thread hop per request —
|
|
9
|
+
the same trade-off documented on :class:`KerniaView`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from asgiref.sync import async_to_sync
|
|
17
|
+
from django.http import HttpRequest
|
|
18
|
+
from django.utils.deprecation import MiddlewareMixin
|
|
19
|
+
from kernia.auth import Kernia
|
|
20
|
+
from kernia.integrations.session import (
|
|
21
|
+
SESSION_COOKIE_NAME,
|
|
22
|
+
resolve_session,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class KerniaMiddleware(MiddlewareMixin):
|
|
27
|
+
"""Attach ``request.kernia_session`` and ``request.kernia_user``.
|
|
28
|
+
|
|
29
|
+
The auth instance is sourced from ``settings.KERNIA`` (the user's
|
|
30
|
+
own ``Kernia`` object). Falling back to a no-op if the setting isn't
|
|
31
|
+
configured keeps the middleware importable from tests that don't wire it.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def process_request(self, request: HttpRequest) -> None:
|
|
35
|
+
auth = self._auth_from_settings()
|
|
36
|
+
request.kernia_session = None # type: ignore[attr-defined]
|
|
37
|
+
request.kernia_user = None # type: ignore[attr-defined]
|
|
38
|
+
if auth is None:
|
|
39
|
+
return
|
|
40
|
+
cookie = request.COOKIES.get(SESSION_COOKIE_NAME)
|
|
41
|
+
if not cookie:
|
|
42
|
+
return
|
|
43
|
+
session = async_to_sync(resolve_session)(auth, cookie)
|
|
44
|
+
if session is None:
|
|
45
|
+
return
|
|
46
|
+
request.kernia_session = session # type: ignore[attr-defined]
|
|
47
|
+
# Lazily resolve the user only when asked; many requests just need the
|
|
48
|
+
# session id. We expose a thin callable proxy via attribute access.
|
|
49
|
+
request.kernia_user = _UserAccessor(auth, session.user_id) # type: ignore[attr-defined]
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _auth_from_settings() -> Kernia | None:
|
|
53
|
+
from django.conf import settings
|
|
54
|
+
|
|
55
|
+
return getattr(settings, "KERNIA", None)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _UserAccessor:
|
|
59
|
+
"""Lazy user lookup.
|
|
60
|
+
|
|
61
|
+
``request.kernia_user`` is a callable that loads the user row on first
|
|
62
|
+
access; we keep it lazy so the middleware only pays for the session lookup
|
|
63
|
+
unless the view actually wants the user.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, auth: Kernia, user_id: str) -> None:
|
|
67
|
+
self._auth = auth
|
|
68
|
+
self._user_id = user_id
|
|
69
|
+
self._cached: dict[str, Any] | None = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def id(self) -> str:
|
|
73
|
+
return self._user_id
|
|
74
|
+
|
|
75
|
+
def load(self) -> dict[str, Any] | None:
|
|
76
|
+
if self._cached is None:
|
|
77
|
+
from kernia.types.adapter import Where
|
|
78
|
+
|
|
79
|
+
self._cached = async_to_sync(self._auth.context.adapter.find_one)(
|
|
80
|
+
model="user",
|
|
81
|
+
where=(Where(field="id", value=self._user_id),),
|
|
82
|
+
)
|
|
83
|
+
return self._cached
|
|
File without changes
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Django view that bridges to the Kernia ASGI router.
|
|
2
|
+
|
|
3
|
+
Approach: Django's request/response objects are a different shape from ASGI, so
|
|
4
|
+
the bridge converts a Django ``HttpRequest`` into an ASGI ``scope`` + receive
|
|
5
|
+
queue, drives the inner router, and reassembles the response body / headers
|
|
6
|
+
into a Django ``HttpResponse``.
|
|
7
|
+
|
|
8
|
+
The bridge is sync at the Django edge (``View.dispatch`` is sync by default)
|
|
9
|
+
and async on the inside. We use ``asgiref.sync.async_to_sync`` to cross the
|
|
10
|
+
boundary. Every Kernia request therefore costs one thread hop. This is a
|
|
11
|
+
known and intentional tradeoff: Django remains sync-friendly without forcing
|
|
12
|
+
ASGI workers on the user.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from http.cookies import SimpleCookie
|
|
18
|
+
from typing import Any, ClassVar
|
|
19
|
+
|
|
20
|
+
from asgiref.sync import async_to_sync
|
|
21
|
+
from django.http import HttpRequest, HttpResponse
|
|
22
|
+
from django.urls import path
|
|
23
|
+
from django.views import View
|
|
24
|
+
from kernia.auth import Kernia
|
|
25
|
+
from kernia.integrations.session import strip_base_path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def django_request_to_scope(
|
|
29
|
+
request: HttpRequest,
|
|
30
|
+
*,
|
|
31
|
+
base_path: str = "",
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
"""Translate a Django HttpRequest into an ASGI HTTP scope.
|
|
34
|
+
|
|
35
|
+
``base_path`` is the mount prefix the auth router was registered under; we
|
|
36
|
+
strip it from ``path`` so the inner router sees canonical relative paths
|
|
37
|
+
(e.g. ``/sign-in/email``). Set to ``""`` to leave the path alone.
|
|
38
|
+
"""
|
|
39
|
+
full_path = request.path
|
|
40
|
+
# Build a minimal ASGI HTTP scope. We don't try to be exhaustive — only the
|
|
41
|
+
# fields Kernia actually reads (path, method, headers, query_string,
|
|
42
|
+
# raw_path, scheme, client).
|
|
43
|
+
headers: list[tuple[bytes, bytes]] = []
|
|
44
|
+
for key, value in request.META.items():
|
|
45
|
+
if key.startswith("HTTP_"):
|
|
46
|
+
name = key[5:].replace("_", "-").lower().encode("latin-1")
|
|
47
|
+
headers.append((name, str(value).encode("latin-1")))
|
|
48
|
+
elif key in ("CONTENT_TYPE", "CONTENT_LENGTH") and value:
|
|
49
|
+
name = key.replace("_", "-").lower().encode("latin-1")
|
|
50
|
+
headers.append((name, str(value).encode("latin-1")))
|
|
51
|
+
|
|
52
|
+
# Django's request.GET is already parsed; the router doesn't need it as a
|
|
53
|
+
# mapping, but does inspect raw query_string from scope.
|
|
54
|
+
query_string = request.META.get("QUERY_STRING", "")
|
|
55
|
+
if isinstance(query_string, str):
|
|
56
|
+
query_string = query_string.encode("latin-1")
|
|
57
|
+
|
|
58
|
+
scope: dict[str, Any] = {
|
|
59
|
+
"type": "http",
|
|
60
|
+
"asgi": {"version": "3.0", "spec_version": "2.3"},
|
|
61
|
+
"http_version": "1.1",
|
|
62
|
+
"method": request.method or "GET",
|
|
63
|
+
"scheme": request.scheme or "http",
|
|
64
|
+
"path": full_path,
|
|
65
|
+
"raw_path": full_path.encode("latin-1"),
|
|
66
|
+
"query_string": query_string,
|
|
67
|
+
"root_path": "",
|
|
68
|
+
"headers": headers,
|
|
69
|
+
"server": (request.get_host().split(":")[0], request.get_port() or 80),
|
|
70
|
+
"client": (request.META.get("REMOTE_ADDR", "") or "", 0),
|
|
71
|
+
}
|
|
72
|
+
if base_path:
|
|
73
|
+
scope = strip_base_path(scope, base_path)
|
|
74
|
+
return scope
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _drive(
|
|
78
|
+
inner: Any,
|
|
79
|
+
scope: dict[str, Any],
|
|
80
|
+
body: bytes,
|
|
81
|
+
) -> tuple[int, list[tuple[bytes, bytes]], bytes]:
|
|
82
|
+
"""Drive a single HTTP exchange against an ASGI app, return the response."""
|
|
83
|
+
sent_body = bytearray()
|
|
84
|
+
status: int = 500
|
|
85
|
+
headers: list[tuple[bytes, bytes]] = []
|
|
86
|
+
|
|
87
|
+
request_sent = False
|
|
88
|
+
|
|
89
|
+
async def receive() -> dict[str, Any]:
|
|
90
|
+
nonlocal request_sent
|
|
91
|
+
if not request_sent:
|
|
92
|
+
request_sent = True
|
|
93
|
+
return {"type": "http.request", "body": body, "more_body": False}
|
|
94
|
+
# If the app asks for more we report disconnect to avoid hanging.
|
|
95
|
+
return {"type": "http.disconnect"}
|
|
96
|
+
|
|
97
|
+
async def send(message: dict[str, Any]) -> None:
|
|
98
|
+
nonlocal status, headers
|
|
99
|
+
if message["type"] == "http.response.start":
|
|
100
|
+
status = int(message["status"])
|
|
101
|
+
headers = list(message.get("headers", []))
|
|
102
|
+
elif message["type"] == "http.response.body":
|
|
103
|
+
chunk = message.get("body", b"") or b""
|
|
104
|
+
sent_body.extend(chunk)
|
|
105
|
+
|
|
106
|
+
await inner(scope, receive, send)
|
|
107
|
+
return status, headers, bytes(sent_body)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class KerniaView(View):
|
|
111
|
+
"""Class-based view that funnels every method onto the auth router.
|
|
112
|
+
|
|
113
|
+
Usage::
|
|
114
|
+
|
|
115
|
+
KerniaView.as_view(auth=my_auth, base_path="/api/auth")
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
auth: Kernia | None = None
|
|
119
|
+
base_path: str = "/api/auth"
|
|
120
|
+
|
|
121
|
+
http_method_names: ClassVar[list[str]] = [
|
|
122
|
+
"get",
|
|
123
|
+
"post",
|
|
124
|
+
"put",
|
|
125
|
+
"patch",
|
|
126
|
+
"delete",
|
|
127
|
+
"head",
|
|
128
|
+
"options",
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
132
|
+
if self.auth is None:
|
|
133
|
+
raise RuntimeError("KerniaView requires an `auth` kwarg passed via .as_view()")
|
|
134
|
+
# ``rest`` is captured by the URL pattern; rebuild the full path because
|
|
135
|
+
# the inner router decides routing from scope["path"].
|
|
136
|
+
rest = kwargs.get("rest", "")
|
|
137
|
+
full_path = self.base_path.rstrip("/") + "/" + rest if rest else self.base_path
|
|
138
|
+
# Rewrite the Django request's path so the scope mirrors what would
|
|
139
|
+
# have been hit if Django were ASGI-native.
|
|
140
|
+
request.path = full_path
|
|
141
|
+
request.path_info = full_path
|
|
142
|
+
|
|
143
|
+
inner = self.auth.router.mount()
|
|
144
|
+
scope = django_request_to_scope(request, base_path=self.base_path.rstrip("/"))
|
|
145
|
+
body = request.body or b""
|
|
146
|
+
|
|
147
|
+
status, headers, response_body = async_to_sync(_drive)(inner, scope, body)
|
|
148
|
+
|
|
149
|
+
# Build Django response. Pull Content-Type for HttpResponse's ctor;
|
|
150
|
+
# parse Set-Cookie lines into response.cookies so Django emits each
|
|
151
|
+
# cookie on its own header (joining Set-Cookie with commas is broken
|
|
152
|
+
# for cookies whose attributes contain commas, e.g. ``Expires``).
|
|
153
|
+
content_type = None
|
|
154
|
+
cookie_lines: list[str] = []
|
|
155
|
+
remaining: list[tuple[bytes, bytes]] = []
|
|
156
|
+
for k, v in headers:
|
|
157
|
+
name = k.decode("latin-1")
|
|
158
|
+
value = v.decode("latin-1")
|
|
159
|
+
if name.lower() == "content-type" and content_type is None:
|
|
160
|
+
content_type = value
|
|
161
|
+
elif name.lower() == "set-cookie":
|
|
162
|
+
cookie_lines.append(value)
|
|
163
|
+
else:
|
|
164
|
+
remaining.append((k, v))
|
|
165
|
+
|
|
166
|
+
resp = HttpResponse(
|
|
167
|
+
response_body,
|
|
168
|
+
status=status,
|
|
169
|
+
content_type=content_type,
|
|
170
|
+
)
|
|
171
|
+
for k, v in remaining:
|
|
172
|
+
resp.headers[k.decode("latin-1")] = v.decode("latin-1")
|
|
173
|
+
for line in cookie_lines:
|
|
174
|
+
jar: SimpleCookie = SimpleCookie()
|
|
175
|
+
jar.load(line)
|
|
176
|
+
for morsel in jar.values():
|
|
177
|
+
resp.cookies[morsel.key] = morsel.value
|
|
178
|
+
for attr in (
|
|
179
|
+
"expires",
|
|
180
|
+
"path",
|
|
181
|
+
"domain",
|
|
182
|
+
"secure",
|
|
183
|
+
"httponly",
|
|
184
|
+
"samesite",
|
|
185
|
+
"max-age",
|
|
186
|
+
):
|
|
187
|
+
if morsel[attr]:
|
|
188
|
+
resp.cookies[morsel.key][attr] = morsel[attr]
|
|
189
|
+
return resp
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def setup(
|
|
193
|
+
auth: Kernia,
|
|
194
|
+
url_prefix: str = "/api/auth",
|
|
195
|
+
) -> list[Any]:
|
|
196
|
+
"""Return a list of urlpatterns ready to splice into ``urls.py``.
|
|
197
|
+
|
|
198
|
+
The patterns use a catch-all ``<path:rest>`` so every sub-route under the
|
|
199
|
+
prefix lands on ``KerniaView``.
|
|
200
|
+
"""
|
|
201
|
+
prefix = url_prefix.strip("/")
|
|
202
|
+
return [
|
|
203
|
+
path(
|
|
204
|
+
f"{prefix}/<path:rest>",
|
|
205
|
+
KerniaView.as_view(auth=auth, base_path="/" + prefix),
|
|
206
|
+
),
|
|
207
|
+
path(
|
|
208
|
+
f"{prefix}",
|
|
209
|
+
KerniaView.as_view(auth=auth, base_path="/" + prefix),
|
|
210
|
+
),
|
|
211
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Unit tests for the Django request → ASGI scope translator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
pytest.importorskip("django")
|
|
8
|
+
|
|
9
|
+
import django
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ensure_django() -> None:
|
|
14
|
+
if not settings.configured:
|
|
15
|
+
settings.configure(
|
|
16
|
+
DEBUG=True,
|
|
17
|
+
SECRET_KEY="test-secret",
|
|
18
|
+
DATABASES={
|
|
19
|
+
"default": {
|
|
20
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
21
|
+
"NAME": ":memory:",
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
INSTALLED_APPS=["kernia_django"],
|
|
25
|
+
MIDDLEWARE=[],
|
|
26
|
+
ROOT_URLCONF=None,
|
|
27
|
+
ALLOWED_HOSTS=["*"],
|
|
28
|
+
USE_TZ=True,
|
|
29
|
+
)
|
|
30
|
+
django.setup()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_ensure_django()
|
|
34
|
+
|
|
35
|
+
from django.test import RequestFactory # noqa: E402
|
|
36
|
+
from kernia_django.views import django_request_to_scope # noqa: E402
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_translator_basic_get() -> None:
|
|
40
|
+
rf = RequestFactory()
|
|
41
|
+
req = rf.get(
|
|
42
|
+
"/api/auth/get-session?foo=bar",
|
|
43
|
+
HTTP_COOKIE="better-auth.session_token=abc",
|
|
44
|
+
HTTP_X_CUSTOM="yes",
|
|
45
|
+
)
|
|
46
|
+
scope = django_request_to_scope(req)
|
|
47
|
+
assert scope["type"] == "http"
|
|
48
|
+
assert scope["method"] == "GET"
|
|
49
|
+
assert scope["path"] == "/api/auth/get-session"
|
|
50
|
+
assert scope["query_string"] == b"foo=bar"
|
|
51
|
+
headers = dict(scope["headers"])
|
|
52
|
+
assert headers[b"cookie"] == b"better-auth.session_token=abc"
|
|
53
|
+
assert headers[b"x-custom"] == b"yes"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_translator_strips_base_path() -> None:
|
|
57
|
+
rf = RequestFactory()
|
|
58
|
+
req = rf.get("/api/auth/sign-in/email")
|
|
59
|
+
scope = django_request_to_scope(req, base_path="/api/auth")
|
|
60
|
+
assert scope["path"] == "/sign-in/email"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_translator_post_includes_content_type() -> None:
|
|
64
|
+
rf = RequestFactory()
|
|
65
|
+
req = rf.post(
|
|
66
|
+
"/api/auth/sign-up/email",
|
|
67
|
+
data='{"email":"x@y.com"}',
|
|
68
|
+
content_type="application/json",
|
|
69
|
+
)
|
|
70
|
+
scope = django_request_to_scope(req, base_path="/api/auth")
|
|
71
|
+
assert scope["method"] == "POST"
|
|
72
|
+
assert scope["path"] == "/sign-up/email"
|
|
73
|
+
headers = dict(scope["headers"])
|
|
74
|
+
assert headers[b"content-type"] == b"application/json"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_translator_root_path_becomes_slash() -> None:
|
|
78
|
+
rf = RequestFactory()
|
|
79
|
+
req = rf.get("/api/auth")
|
|
80
|
+
scope = django_request_to_scope(req, base_path="/api/auth")
|
|
81
|
+
assert scope["path"] == "/"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_translator_non_matching_base_path_unchanged() -> None:
|
|
85
|
+
rf = RequestFactory()
|
|
86
|
+
req = rf.get("/elsewhere")
|
|
87
|
+
scope = django_request_to_scope(req, base_path="/api/auth")
|
|
88
|
+
assert scope["path"] == "/elsewhere"
|