hayate-auth 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.
- hayate_auth-0.1.0/LICENSE +21 -0
- hayate_auth-0.1.0/PKG-INFO +90 -0
- hayate_auth-0.1.0/README.md +71 -0
- hayate_auth-0.1.0/pyproject.toml +46 -0
- hayate_auth-0.1.0/src/hayate_auth/__init__.py +25 -0
- hayate_auth-0.1.0/src/hayate_auth/_uuid7.py +34 -0
- hayate_auth-0.1.0/src/hayate_auth/adapter.py +39 -0
- hayate_auth-0.1.0/src/hayate_auth/adapters/__init__.py +0 -0
- hayate_auth-0.1.0/src/hayate_auth/adapters/sqlite.py +142 -0
- hayate_auth-0.1.0/src/hayate_auth/auth.py +107 -0
- hayate_auth-0.1.0/src/hayate_auth/crypto/__init__.py +188 -0
- hayate_auth-0.1.0/src/hayate_auth/csrf.py +28 -0
- hayate_auth-0.1.0/src/hayate_auth/password.py +36 -0
- hayate_auth-0.1.0/src/hayate_auth/routes.py +160 -0
- hayate_auth-0.1.0/src/hayate_auth/schema.py +77 -0
- hayate_auth-0.1.0/src/hayate_auth/session.py +119 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yusuke Hayashi
|
|
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,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hayate-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Authentication for hayate as a pure fetch(Request) -> Response handler: email+password, sessions, CSRF
|
|
5
|
+
Keywords: hayate,authentication,session,better-auth,workers
|
|
6
|
+
Author: Yusuke Hayashi
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Session
|
|
15
|
+
Requires-Dist: hayate>=0.6.0
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Project-URL: Repository, https://github.com/hayatepy/hayate-auth
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# hayate-auth
|
|
21
|
+
|
|
22
|
+
Standards-first authentication for [hayate](https://github.com/hayatepy/hayate) —
|
|
23
|
+
a mountable, better-auth-style auth handler built on the WHATWG Request/Response
|
|
24
|
+
model.
|
|
25
|
+
|
|
26
|
+
> **Status: alpha (0.1.x).** Email+password, sessions, and CSRF are implemented
|
|
27
|
+
> and attack-regression-tested; email verification and OAuth land in 0.2. Not
|
|
28
|
+
> yet security-audited — see [SECURITY.md](SECURITY.md). The internal design
|
|
29
|
+
> memo (Japanese, per project convention) lives in [DESIGN.md](DESIGN.md).
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import os
|
|
33
|
+
|
|
34
|
+
from hayate import Hayate
|
|
35
|
+
from hayate_auth import Auth
|
|
36
|
+
from hayate_auth.adapters.sqlite import SQLiteAdapter
|
|
37
|
+
|
|
38
|
+
adapter = SQLiteAdapter("app.db")
|
|
39
|
+
adapter.create_tables()
|
|
40
|
+
|
|
41
|
+
auth = Auth(secret=os.environ["AUTH_SECRET"], adapter=adapter)
|
|
42
|
+
|
|
43
|
+
app = Hayate()
|
|
44
|
+
auth.register(app) # serves /api/auth/* (sign-up, sign-in, session, ...)
|
|
45
|
+
|
|
46
|
+
@app.get("/me", auth.require_session())
|
|
47
|
+
async def me(c):
|
|
48
|
+
return c.json(c.get("user"))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The same file runs under any ASGI server and on Cloudflare Python Workers —
|
|
52
|
+
see [examples/todo](examples/todo).
|
|
53
|
+
|
|
54
|
+
## Endpoints (v0.1)
|
|
55
|
+
|
|
56
|
+
| Method / path (under `/api/auth`) | Purpose |
|
|
57
|
+
|---|---|
|
|
58
|
+
| POST `/sign-up/email` | Register with email + password, start a session |
|
|
59
|
+
| POST `/sign-in/email` | Verify credentials, start a session |
|
|
60
|
+
| GET `/get-session` | Current `{user, session}` (or nulls) |
|
|
61
|
+
| POST `/sign-out` | Revoke the session server-side |
|
|
62
|
+
|
|
63
|
+
## Why
|
|
64
|
+
|
|
65
|
+
- Python has no equivalent of better-auth: a framework-agnostic, self-hosted,
|
|
66
|
+
schema-owning auth *library*. django-allauth is Django-only; fastapi-users is
|
|
67
|
+
in maintenance mode.
|
|
68
|
+
- better-auth works on every JS framework because its core is a single
|
|
69
|
+
`fetch(Request) -> Response` handler. hayate is the only Python framework
|
|
70
|
+
whose user-facing surface *is* WHATWG Request/Response — so that architecture
|
|
71
|
+
finally maps 1:1 to Python.
|
|
72
|
+
- Zero-dependency core (its only dependency is hayate, itself zero-dependency).
|
|
73
|
+
Databases, KDFs, and email are injected protocols.
|
|
74
|
+
|
|
75
|
+
## Security posture
|
|
76
|
+
|
|
77
|
+
- Passwords: scrypt at OWASP parameters (N=2^17, r=8, p=1) on every runtime,
|
|
78
|
+
PBKDF2-HMAC-SHA256 (600k) fallback; PHC-style strings make the backends
|
|
79
|
+
mutually verifiable. Length-only policy per NIST SP 800-63B.
|
|
80
|
+
- Sessions: opaque 256-bit tokens, only their SHA-256 stored;
|
|
81
|
+
`__Host-`-prefixed HttpOnly SameSite=Lax cookies on HTTPS.
|
|
82
|
+
- CSRF: SameSite + Origin (RFC 6454) + Fetch Metadata — no token embedding.
|
|
83
|
+
- Sign-in failures are uniform in body and KDF timing (enumeration defense).
|
|
84
|
+
- Coverage ledger: [docs/asvs.md](docs/asvs.md) (OWASP ASVS V6/V7, ratcheted).
|
|
85
|
+
- **You must rate-limit** `/api/auth/*` (hayate middleware or your
|
|
86
|
+
infrastructure): brute-force throttling is deliberately out of core.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# hayate-auth
|
|
2
|
+
|
|
3
|
+
Standards-first authentication for [hayate](https://github.com/hayatepy/hayate) —
|
|
4
|
+
a mountable, better-auth-style auth handler built on the WHATWG Request/Response
|
|
5
|
+
model.
|
|
6
|
+
|
|
7
|
+
> **Status: alpha (0.1.x).** Email+password, sessions, and CSRF are implemented
|
|
8
|
+
> and attack-regression-tested; email verification and OAuth land in 0.2. Not
|
|
9
|
+
> yet security-audited — see [SECURITY.md](SECURITY.md). The internal design
|
|
10
|
+
> memo (Japanese, per project convention) lives in [DESIGN.md](DESIGN.md).
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from hayate import Hayate
|
|
16
|
+
from hayate_auth import Auth
|
|
17
|
+
from hayate_auth.adapters.sqlite import SQLiteAdapter
|
|
18
|
+
|
|
19
|
+
adapter = SQLiteAdapter("app.db")
|
|
20
|
+
adapter.create_tables()
|
|
21
|
+
|
|
22
|
+
auth = Auth(secret=os.environ["AUTH_SECRET"], adapter=adapter)
|
|
23
|
+
|
|
24
|
+
app = Hayate()
|
|
25
|
+
auth.register(app) # serves /api/auth/* (sign-up, sign-in, session, ...)
|
|
26
|
+
|
|
27
|
+
@app.get("/me", auth.require_session())
|
|
28
|
+
async def me(c):
|
|
29
|
+
return c.json(c.get("user"))
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The same file runs under any ASGI server and on Cloudflare Python Workers —
|
|
33
|
+
see [examples/todo](examples/todo).
|
|
34
|
+
|
|
35
|
+
## Endpoints (v0.1)
|
|
36
|
+
|
|
37
|
+
| Method / path (under `/api/auth`) | Purpose |
|
|
38
|
+
|---|---|
|
|
39
|
+
| POST `/sign-up/email` | Register with email + password, start a session |
|
|
40
|
+
| POST `/sign-in/email` | Verify credentials, start a session |
|
|
41
|
+
| GET `/get-session` | Current `{user, session}` (or nulls) |
|
|
42
|
+
| POST `/sign-out` | Revoke the session server-side |
|
|
43
|
+
|
|
44
|
+
## Why
|
|
45
|
+
|
|
46
|
+
- Python has no equivalent of better-auth: a framework-agnostic, self-hosted,
|
|
47
|
+
schema-owning auth *library*. django-allauth is Django-only; fastapi-users is
|
|
48
|
+
in maintenance mode.
|
|
49
|
+
- better-auth works on every JS framework because its core is a single
|
|
50
|
+
`fetch(Request) -> Response` handler. hayate is the only Python framework
|
|
51
|
+
whose user-facing surface *is* WHATWG Request/Response — so that architecture
|
|
52
|
+
finally maps 1:1 to Python.
|
|
53
|
+
- Zero-dependency core (its only dependency is hayate, itself zero-dependency).
|
|
54
|
+
Databases, KDFs, and email are injected protocols.
|
|
55
|
+
|
|
56
|
+
## Security posture
|
|
57
|
+
|
|
58
|
+
- Passwords: scrypt at OWASP parameters (N=2^17, r=8, p=1) on every runtime,
|
|
59
|
+
PBKDF2-HMAC-SHA256 (600k) fallback; PHC-style strings make the backends
|
|
60
|
+
mutually verifiable. Length-only policy per NIST SP 800-63B.
|
|
61
|
+
- Sessions: opaque 256-bit tokens, only their SHA-256 stored;
|
|
62
|
+
`__Host-`-prefixed HttpOnly SameSite=Lax cookies on HTTPS.
|
|
63
|
+
- CSRF: SameSite + Origin (RFC 6454) + Fetch Metadata — no token embedding.
|
|
64
|
+
- Sign-in failures are uniform in body and KDF timing (enumeration defense).
|
|
65
|
+
- Coverage ledger: [docs/asvs.md](docs/asvs.md) (OWASP ASVS V6/V7, ratcheted).
|
|
66
|
+
- **You must rate-limit** `/api/auth/*` (hayate middleware or your
|
|
67
|
+
infrastructure): brute-force throttling is deliberately out of core.
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.9,<1"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hayate-auth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Authentication for hayate as a pure fetch(Request) -> Response handler: email+password, sessions, CSRF"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Yusuke Hayashi" }]
|
|
14
|
+
keywords = ["hayate", "authentication", "session", "better-auth", "workers"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Internet :: WWW/HTTP :: Session",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["hayate>=0.6.0"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Repository = "https://github.com/hayatepy/hayate-auth"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.3",
|
|
31
|
+
"pytest-asyncio>=0.25",
|
|
32
|
+
"ruff>=0.9",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
asyncio_mode = "auto"
|
|
37
|
+
testpaths = ["tests"]
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
line-length = 100
|
|
41
|
+
target-version = "py312"
|
|
42
|
+
src = ["src", "tests"]
|
|
43
|
+
extend-exclude = ["spike"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""hayate-auth: authentication for hayate as a pure fetch handler."""
|
|
2
|
+
|
|
3
|
+
from .adapter import Adapter, Where
|
|
4
|
+
from .auth import Auth
|
|
5
|
+
from .crypto import (
|
|
6
|
+
CryptoBackend,
|
|
7
|
+
Pbkdf2Backend,
|
|
8
|
+
ScryptBackend,
|
|
9
|
+
UnsupportedHashError,
|
|
10
|
+
default_backend,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Adapter",
|
|
17
|
+
"Auth",
|
|
18
|
+
"CryptoBackend",
|
|
19
|
+
"Pbkdf2Backend",
|
|
20
|
+
"ScryptBackend",
|
|
21
|
+
"UnsupportedHashError",
|
|
22
|
+
"Where",
|
|
23
|
+
"__version__",
|
|
24
|
+
"default_backend",
|
|
25
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""UUIDv7 (RFC 9562): time-ordered primary keys for every model.
|
|
2
|
+
|
|
3
|
+
Python 3.14 ships ``uuid.uuid7``; on 3.12/3.13 this falls back to a compliant
|
|
4
|
+
implementation (48-bit Unix milliseconds + 74 random bits). Within the same
|
|
5
|
+
millisecond the fallback gives no ordering guarantee, which is fine for
|
|
6
|
+
primary keys — the point is coarse time-sortability, not a sequence.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
if hasattr(uuid, "uuid7"): # Python 3.14+
|
|
16
|
+
uuid7 = uuid.uuid7
|
|
17
|
+
else:
|
|
18
|
+
|
|
19
|
+
def uuid7() -> uuid.UUID:
|
|
20
|
+
ts_ms = time.time_ns() // 1_000_000
|
|
21
|
+
rand = int.from_bytes(os.urandom(10), "big")
|
|
22
|
+
value = (
|
|
23
|
+
(ts_ms & 0xFFFF_FFFF_FFFF) << 80
|
|
24
|
+
| 0x7 << 76
|
|
25
|
+
| ((rand >> 62) & 0xFFF) << 64
|
|
26
|
+
| 0b10 << 62
|
|
27
|
+
| (rand & 0x3FFF_FFFF_FFFF_FFFF)
|
|
28
|
+
)
|
|
29
|
+
return uuid.UUID(int=value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def new_id() -> str:
|
|
33
|
+
"""A fresh UUIDv7 in the canonical text form used as primary key."""
|
|
34
|
+
return str(uuid7())
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The Adapter protocol (DESIGN §5): minimal model-name + dict CRUD.
|
|
2
|
+
|
|
3
|
+
Database libraries implement these five methods; everything else in
|
|
4
|
+
hayate-auth is written against them. ``Where`` deliberately supports only
|
|
5
|
+
the four operators the core actually uses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import Any, Literal, NamedTuple, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Where(NamedTuple):
|
|
15
|
+
field: str
|
|
16
|
+
value: Any
|
|
17
|
+
op: Literal["eq", "lt", "gt", "in"] = "eq"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class Adapter(Protocol):
|
|
22
|
+
async def create(self, model: str, data: dict[str, Any]) -> dict[str, Any]: ...
|
|
23
|
+
|
|
24
|
+
async def find_one(self, model: str, where: Sequence[Where]) -> dict[str, Any] | None: ...
|
|
25
|
+
|
|
26
|
+
async def find_many(
|
|
27
|
+
self,
|
|
28
|
+
model: str,
|
|
29
|
+
where: Sequence[Where],
|
|
30
|
+
*,
|
|
31
|
+
limit: int | None = None,
|
|
32
|
+
sort: tuple[str, str] | None = None,
|
|
33
|
+
) -> list[dict[str, Any]]: ...
|
|
34
|
+
|
|
35
|
+
async def update(
|
|
36
|
+
self, model: str, where: Sequence[Where], data: dict[str, Any]
|
|
37
|
+
) -> dict[str, Any] | None: ...
|
|
38
|
+
|
|
39
|
+
async def delete(self, model: str, where: Sequence[Where]) -> int: ...
|
|
File without changes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Reference Adapter on stdlib sqlite3 (DESIGN §5).
|
|
2
|
+
|
|
3
|
+
A single shared connection guarded by a lock, with every blocking call pushed
|
|
4
|
+
through ``asyncio.to_thread`` (called inline on Pyodide, which has no
|
|
5
|
+
threads). Model and field names are validated against ``schema.MODELS``
|
|
6
|
+
before they are interpolated, which is what makes the SQL composition safe.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import sqlite3
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
from collections.abc import Callable, Sequence
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ..adapter import Where
|
|
19
|
+
from ..schema import MODELS, SQLITE_SCHEMA
|
|
20
|
+
|
|
21
|
+
_OPS = {"eq": "=", "lt": "<", "gt": ">"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate(model: str, fields: Sequence[str]) -> None:
|
|
25
|
+
columns = MODELS.get(model)
|
|
26
|
+
if columns is None:
|
|
27
|
+
raise ValueError(f"unknown model {model!r}")
|
|
28
|
+
for field in fields:
|
|
29
|
+
if field not in columns:
|
|
30
|
+
raise ValueError(f"unknown field {model}.{field}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _where_sql(model: str, where: Sequence[Where]) -> tuple[str, list[Any]]:
|
|
34
|
+
_validate(model, [w.field for w in where])
|
|
35
|
+
if not where:
|
|
36
|
+
return "", []
|
|
37
|
+
clauses: list[str] = []
|
|
38
|
+
params: list[Any] = []
|
|
39
|
+
for w in where:
|
|
40
|
+
if w.op == "in":
|
|
41
|
+
values = list(w.value)
|
|
42
|
+
placeholders = ", ".join("?" * len(values))
|
|
43
|
+
clauses.append(f'"{w.field}" IN ({placeholders})')
|
|
44
|
+
params.extend(values)
|
|
45
|
+
else:
|
|
46
|
+
clauses.append(f'"{w.field}" {_OPS[w.op]} ?')
|
|
47
|
+
params.append(w.value)
|
|
48
|
+
return " WHERE " + " AND ".join(clauses), params
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SQLiteAdapter:
|
|
52
|
+
def __init__(self, path: str = ":memory:") -> None:
|
|
53
|
+
self._conn = sqlite3.connect(path, check_same_thread=False)
|
|
54
|
+
self._conn.row_factory = sqlite3.Row
|
|
55
|
+
self._conn.execute("PRAGMA foreign_keys = ON")
|
|
56
|
+
self._lock = threading.Lock()
|
|
57
|
+
|
|
58
|
+
def create_tables(self) -> None:
|
|
59
|
+
"""Create the hayate-auth schema. Explicit by design: hayate-auth
|
|
60
|
+
never mutates a database schema behind your back (DESIGN §4)."""
|
|
61
|
+
with self._lock, self._conn:
|
|
62
|
+
self._conn.executescript(SQLITE_SCHEMA)
|
|
63
|
+
|
|
64
|
+
def close(self) -> None:
|
|
65
|
+
self._conn.close()
|
|
66
|
+
|
|
67
|
+
async def _run(self, fn: Callable[[], Any]) -> Any:
|
|
68
|
+
if sys.platform == "emscripten": # no threads on Pyodide
|
|
69
|
+
return fn()
|
|
70
|
+
return await asyncio.to_thread(fn)
|
|
71
|
+
|
|
72
|
+
async def create(self, model: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
73
|
+
_validate(model, list(data))
|
|
74
|
+
columns = ", ".join(f'"{k}"' for k in data)
|
|
75
|
+
placeholders = ", ".join("?" * len(data))
|
|
76
|
+
sql = f'INSERT INTO "{model}" ({columns}) VALUES ({placeholders})'
|
|
77
|
+
params = list(data.values())
|
|
78
|
+
|
|
79
|
+
def run() -> dict[str, Any]:
|
|
80
|
+
with self._lock, self._conn:
|
|
81
|
+
self._conn.execute(sql, params)
|
|
82
|
+
return dict(data)
|
|
83
|
+
|
|
84
|
+
return await self._run(run)
|
|
85
|
+
|
|
86
|
+
async def find_one(self, model: str, where: Sequence[Where]) -> dict[str, Any] | None:
|
|
87
|
+
rows = await self.find_many(model, where, limit=1)
|
|
88
|
+
return rows[0] if rows else None
|
|
89
|
+
|
|
90
|
+
async def find_many(
|
|
91
|
+
self,
|
|
92
|
+
model: str,
|
|
93
|
+
where: Sequence[Where],
|
|
94
|
+
*,
|
|
95
|
+
limit: int | None = None,
|
|
96
|
+
sort: tuple[str, str] | None = None,
|
|
97
|
+
) -> list[dict[str, Any]]:
|
|
98
|
+
_validate(model, ())
|
|
99
|
+
clause, params = _where_sql(model, where)
|
|
100
|
+
sql = f'SELECT * FROM "{model}"{clause}'
|
|
101
|
+
if sort is not None:
|
|
102
|
+
field, direction = sort
|
|
103
|
+
_validate(model, [field])
|
|
104
|
+
if direction.lower() not in ("asc", "desc"):
|
|
105
|
+
raise ValueError(f"sort direction must be asc or desc, got {direction!r}")
|
|
106
|
+
sql += f' ORDER BY "{field}" {direction.upper()}'
|
|
107
|
+
if limit is not None:
|
|
108
|
+
sql += " LIMIT ?"
|
|
109
|
+
params = [*params, int(limit)]
|
|
110
|
+
|
|
111
|
+
def run() -> list[dict[str, Any]]:
|
|
112
|
+
with self._lock:
|
|
113
|
+
rows = self._conn.execute(sql, params).fetchall()
|
|
114
|
+
return [dict(row) for row in rows]
|
|
115
|
+
|
|
116
|
+
return await self._run(run)
|
|
117
|
+
|
|
118
|
+
async def update(
|
|
119
|
+
self, model: str, where: Sequence[Where], data: dict[str, Any]
|
|
120
|
+
) -> dict[str, Any] | None:
|
|
121
|
+
_validate(model, list(data))
|
|
122
|
+
clause, where_params = _where_sql(model, where)
|
|
123
|
+
assignments = ", ".join(f'"{k}" = ?' for k in data)
|
|
124
|
+
sql = f'UPDATE "{model}" SET {assignments}{clause}'
|
|
125
|
+
params = [*data.values(), *where_params]
|
|
126
|
+
|
|
127
|
+
def run() -> None:
|
|
128
|
+
with self._lock, self._conn:
|
|
129
|
+
self._conn.execute(sql, params)
|
|
130
|
+
|
|
131
|
+
await self._run(run)
|
|
132
|
+
return await self.find_one(model, where)
|
|
133
|
+
|
|
134
|
+
async def delete(self, model: str, where: Sequence[Where]) -> int:
|
|
135
|
+
clause, params = _where_sql(model, where)
|
|
136
|
+
sql = f'DELETE FROM "{model}"{clause}'
|
|
137
|
+
|
|
138
|
+
def run() -> int:
|
|
139
|
+
with self._lock, self._conn:
|
|
140
|
+
return self._conn.execute(sql, params).rowcount
|
|
141
|
+
|
|
142
|
+
return await self._run(run)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The Auth core: one pure function, ``fetch(Request) -> Response``
|
|
2
|
+
(DESIGN §3), plus the three sugar helpers an app actually touches.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from hayate import Context, HTTPException, Middleware, Next, Request, Response, problem
|
|
11
|
+
|
|
12
|
+
from . import csrf
|
|
13
|
+
from . import session as sessions
|
|
14
|
+
from .adapter import Adapter, Where
|
|
15
|
+
from .crypto import CryptoBackend, default_backend
|
|
16
|
+
from .routes import ROUTES, public_user
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Auth:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
secret: str,
|
|
24
|
+
adapter: Adapter,
|
|
25
|
+
crypto: CryptoBackend | None = None,
|
|
26
|
+
base_path: str = "/api/auth",
|
|
27
|
+
trusted_origins: tuple[str, ...] | list[str] = (),
|
|
28
|
+
session_ttl: timedelta = timedelta(days=7),
|
|
29
|
+
) -> None:
|
|
30
|
+
if not secret:
|
|
31
|
+
raise ValueError("secret must be a non-empty string")
|
|
32
|
+
if not base_path.startswith("/"):
|
|
33
|
+
raise ValueError("base_path must start with '/'")
|
|
34
|
+
self.secret = secret
|
|
35
|
+
self.adapter = adapter
|
|
36
|
+
self.crypto = crypto if crypto is not None else default_backend()
|
|
37
|
+
self.base_path = base_path.rstrip("/")
|
|
38
|
+
self.trusted_origins = frozenset(trusted_origins)
|
|
39
|
+
self.session_ttl = session_ttl
|
|
40
|
+
self._dummy: str | None = None
|
|
41
|
+
|
|
42
|
+
# -- the core ----------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
async def fetch(self, request: Request) -> Response:
|
|
45
|
+
"""Serve one auth API request. I/O happens only through the injected
|
|
46
|
+
protocols, so tests call this directly with a bare Request."""
|
|
47
|
+
raw = getattr(request, "raw", request)
|
|
48
|
+
path = raw.url.pathname
|
|
49
|
+
if path != self.base_path and not path.startswith(self.base_path + "/"):
|
|
50
|
+
return problem(404, title="Not Found")
|
|
51
|
+
sub = path[len(self.base_path) :]
|
|
52
|
+
|
|
53
|
+
if raw.method == "POST" and not csrf.is_allowed(raw, self.trusted_origins):
|
|
54
|
+
return problem(403, title="Cross-origin request rejected")
|
|
55
|
+
|
|
56
|
+
handler = ROUTES.get((raw.method, sub))
|
|
57
|
+
if handler is None:
|
|
58
|
+
return problem(404, title="Not Found")
|
|
59
|
+
return await handler(self, raw)
|
|
60
|
+
|
|
61
|
+
# -- sugar -------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def register(self, app: Any) -> None:
|
|
64
|
+
"""Mount the auth API: two catch-all routes, nothing else
|
|
65
|
+
(better-auth's Hono recipe, DESIGN §3.2)."""
|
|
66
|
+
|
|
67
|
+
async def auth_handler(c: Context) -> Response:
|
|
68
|
+
return await self.fetch(c.req)
|
|
69
|
+
|
|
70
|
+
pattern = f"{self.base_path}/*"
|
|
71
|
+
app.on("GET", pattern)(auth_handler)
|
|
72
|
+
app.on("POST", pattern)(auth_handler)
|
|
73
|
+
|
|
74
|
+
def require_session(self) -> Middleware:
|
|
75
|
+
"""Middleware: 401 Problem Details unless a valid session cookie is
|
|
76
|
+
presented; on success ``c.get("user")`` / ``c.get("session")`` are set."""
|
|
77
|
+
|
|
78
|
+
async def require_session_middleware(c: Context, next_: Next) -> None:
|
|
79
|
+
resolved = await self.get_session(c.req.raw)
|
|
80
|
+
if resolved is None:
|
|
81
|
+
raise HTTPException(401, title="Authentication required")
|
|
82
|
+
user, record = resolved
|
|
83
|
+
c.set("user", user)
|
|
84
|
+
c.set("session", record)
|
|
85
|
+
await next_()
|
|
86
|
+
|
|
87
|
+
return require_session_middleware
|
|
88
|
+
|
|
89
|
+
async def get_session(self, request: Request) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
|
90
|
+
"""(public user, public session) for the request's cookie, or None."""
|
|
91
|
+
raw = getattr(request, "raw", request)
|
|
92
|
+
record = await sessions.resolve_session(self.adapter, raw)
|
|
93
|
+
if record is None:
|
|
94
|
+
return None
|
|
95
|
+
user_row = await self.adapter.find_one("user", [Where("id", record["user_id"])])
|
|
96
|
+
if user_row is None:
|
|
97
|
+
return None
|
|
98
|
+
return public_user(user_row), sessions.public_session(record)
|
|
99
|
+
|
|
100
|
+
# -- internals ---------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
async def _dummy_hash(self) -> str:
|
|
103
|
+
"""A throwaway hash used to equalize sign-in timing for unknown
|
|
104
|
+
users. Generated once per Auth instance, lazily."""
|
|
105
|
+
if self._dummy is None:
|
|
106
|
+
self._dummy = await self.crypto.hash_password("hayate-auth-timing-dummy")
|
|
107
|
+
return self._dummy
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Password KDF backends (DESIGN §8, decided by the 2026-07-22 spike).
|
|
2
|
+
|
|
3
|
+
Default everywhere is scrypt with OWASP parameters; environments whose
|
|
4
|
+
Pyodide predates the OpenSSL-backed hashlib fall back to PBKDF2-HMAC-SHA256.
|
|
5
|
+
Stored hashes carry a PHC-style algorithm identifier, so verification
|
|
6
|
+
dispatches on the stored string and runtimes can be mixed freely
|
|
7
|
+
(measured cross-runtime known-answer match in docs/research/kdf.md).
|
|
8
|
+
|
|
9
|
+
The iron rule: no self-built crypto primitives. Everything below calls
|
|
10
|
+
``hashlib`` or WebCrypto.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Protocol
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CryptoBackend",
|
|
25
|
+
"Pbkdf2Backend",
|
|
26
|
+
"ScryptBackend",
|
|
27
|
+
"UnsupportedHashError",
|
|
28
|
+
"default_backend",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UnsupportedHashError(Exception):
|
|
33
|
+
"""The stored hash uses an algorithm this runtime cannot verify."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CryptoBackend(Protocol):
|
|
37
|
+
async def hash_password(self, password: str) -> str: ...
|
|
38
|
+
|
|
39
|
+
async def verify_password(self, password: str, stored: str) -> bool: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _b64encode(raw: bytes) -> str:
|
|
43
|
+
return base64.b64encode(raw).decode("ascii").rstrip("=")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _b64decode(text: str) -> bytes:
|
|
47
|
+
return base64.b64decode(text + "=" * (-len(text) % 4))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _off_loop(fn):
|
|
51
|
+
"""Run a CPU-heavy KDF without blocking the event loop.
|
|
52
|
+
|
|
53
|
+
Pyodide has no threads, but there each request owns its isolate, so
|
|
54
|
+
calling inline is acceptable (DESIGN §8).
|
|
55
|
+
"""
|
|
56
|
+
if sys.platform == "emscripten":
|
|
57
|
+
return fn()
|
|
58
|
+
return await asyncio.to_thread(fn)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _have_scrypt() -> bool:
|
|
62
|
+
return hasattr(hashlib, "scrypt")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _have_pbkdf2() -> bool:
|
|
66
|
+
return hasattr(hashlib, "pbkdf2_hmac")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ScryptBackend:
|
|
70
|
+
"""scrypt (RFC 7914), OWASP parameters: N=2^17, r=8, p=1, 64 MiB."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, *, log_n: int = 17, r: int = 8, p: int = 1, dklen: int = 32) -> None:
|
|
73
|
+
self.log_n = log_n
|
|
74
|
+
self.r = r
|
|
75
|
+
self.p = p
|
|
76
|
+
self.dklen = dklen
|
|
77
|
+
|
|
78
|
+
def _derive(self, password: str, salt: bytes, *, log_n: int, r: int, p: int) -> bytes:
|
|
79
|
+
return hashlib.scrypt(
|
|
80
|
+
password.encode("utf-8"),
|
|
81
|
+
salt=salt,
|
|
82
|
+
n=1 << log_n,
|
|
83
|
+
r=r,
|
|
84
|
+
p=p,
|
|
85
|
+
dklen=self.dklen,
|
|
86
|
+
maxmem=2 * 128 * r * (1 << log_n),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async def hash_password(self, password: str) -> str:
|
|
90
|
+
salt = os.urandom(16)
|
|
91
|
+
derived = await _off_loop(
|
|
92
|
+
lambda: self._derive(password, salt, log_n=self.log_n, r=self.r, p=self.p)
|
|
93
|
+
)
|
|
94
|
+
return (
|
|
95
|
+
f"$scrypt$ln={self.log_n},r={self.r},p={self.p}"
|
|
96
|
+
f"${_b64encode(salt)}${_b64encode(derived)}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def verify_password(self, password: str, stored: str) -> bool:
|
|
100
|
+
return await verify_phc(password, stored)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Pbkdf2Backend:
|
|
104
|
+
"""PBKDF2-HMAC-SHA256 (RFC 8018), OWASP 600k iterations.
|
|
105
|
+
|
|
106
|
+
Uses ``hashlib.pbkdf2_hmac`` when present (it is, on current Pyodide);
|
|
107
|
+
otherwise derives through WebCrypto ``deriveBits`` on Workers.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, *, iterations: int = 600_000, dklen: int = 32) -> None:
|
|
111
|
+
self.iterations = iterations
|
|
112
|
+
self.dklen = dklen
|
|
113
|
+
|
|
114
|
+
async def _derive(self, password: str, salt: bytes, iterations: int, dklen: int) -> bytes:
|
|
115
|
+
if _have_pbkdf2():
|
|
116
|
+
return await _off_loop(
|
|
117
|
+
lambda: hashlib.pbkdf2_hmac(
|
|
118
|
+
"sha256", password.encode("utf-8"), salt, iterations, dklen=dklen
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
return await _webcrypto_pbkdf2(password.encode("utf-8"), salt, iterations, dklen)
|
|
122
|
+
|
|
123
|
+
async def hash_password(self, password: str) -> str:
|
|
124
|
+
salt = os.urandom(16)
|
|
125
|
+
derived = await self._derive(password, salt, self.iterations, self.dklen)
|
|
126
|
+
return f"$pbkdf2-sha256$i={self.iterations}${_b64encode(salt)}${_b64encode(derived)}"
|
|
127
|
+
|
|
128
|
+
async def verify_password(self, password: str, stored: str) -> bool:
|
|
129
|
+
return await verify_phc(password, stored)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def _webcrypto_pbkdf2(password: bytes, salt: bytes, iterations: int, dklen: int) -> bytes:
|
|
133
|
+
import js
|
|
134
|
+
from pyodide.ffi import to_js
|
|
135
|
+
|
|
136
|
+
key = await js.crypto.subtle.importKey(
|
|
137
|
+
"raw", to_js(password), "PBKDF2", False, to_js(["deriveBits"])
|
|
138
|
+
)
|
|
139
|
+
algo = to_js(
|
|
140
|
+
{"name": "PBKDF2", "hash": "SHA-256", "salt": salt, "iterations": iterations},
|
|
141
|
+
dict_converter=js.Object.fromEntries,
|
|
142
|
+
)
|
|
143
|
+
bits = await js.crypto.subtle.deriveBits(algo, key, dklen * 8)
|
|
144
|
+
return bytes(js.Uint8Array.new(bits).to_py())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def verify_phc(password: str, stored: str) -> bool:
|
|
148
|
+
"""Verify against any hash this package ever wrote, whichever backend
|
|
149
|
+
wrote it. Raises UnsupportedHashError when the algorithm cannot run here."""
|
|
150
|
+
parts = stored.split("$")
|
|
151
|
+
if len(parts) != 5 or parts[0] != "":
|
|
152
|
+
return False
|
|
153
|
+
_, algorithm, params, salt_b64, hash_b64 = parts
|
|
154
|
+
salt = _b64decode(salt_b64)
|
|
155
|
+
expected = _b64decode(hash_b64)
|
|
156
|
+
|
|
157
|
+
if algorithm == "scrypt":
|
|
158
|
+
if not _have_scrypt():
|
|
159
|
+
raise UnsupportedHashError("stored hash is scrypt but hashlib.scrypt is unavailable")
|
|
160
|
+
values = dict(item.split("=") for item in params.split(","))
|
|
161
|
+
log_n, r, p = int(values["ln"]), int(values["r"]), int(values["p"])
|
|
162
|
+
derived = await _off_loop(
|
|
163
|
+
lambda: hashlib.scrypt(
|
|
164
|
+
password.encode("utf-8"),
|
|
165
|
+
salt=salt,
|
|
166
|
+
n=1 << log_n,
|
|
167
|
+
r=r,
|
|
168
|
+
p=p,
|
|
169
|
+
dklen=len(expected),
|
|
170
|
+
maxmem=2 * 128 * r * (1 << log_n),
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
elif algorithm == "pbkdf2-sha256":
|
|
174
|
+
values = dict(item.split("=") for item in params.split(","))
|
|
175
|
+
iterations = int(values["i"])
|
|
176
|
+
backend = Pbkdf2Backend(iterations=iterations, dklen=len(expected))
|
|
177
|
+
derived = await backend._derive(password, salt, iterations, len(expected))
|
|
178
|
+
else:
|
|
179
|
+
raise UnsupportedHashError(f"unknown password hash algorithm {algorithm!r}")
|
|
180
|
+
|
|
181
|
+
return hmac.compare_digest(derived, expected)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def default_backend() -> CryptoBackend:
|
|
185
|
+
"""Best standard KDF the running interpreter offers (DESIGN §8)."""
|
|
186
|
+
if _have_scrypt():
|
|
187
|
+
return ScryptBackend()
|
|
188
|
+
return Pbkdf2Backend()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""CSRF via standard headers only (DESIGN §9): Origin (RFC 6454) first,
|
|
2
|
+
W3C Fetch Metadata (Sec-Fetch-Site) as the fallback signal. No token
|
|
3
|
+
embedding — SameSite=Lax cookies are the first line of defense and this
|
|
4
|
+
check is the second.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from hayate import Request
|
|
10
|
+
|
|
11
|
+
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_allowed(request: Request, trusted_origins: frozenset[str]) -> bool:
|
|
15
|
+
if request.method in _SAFE_METHODS:
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
origin = request.headers.get("origin")
|
|
19
|
+
if origin is not None and origin != "null":
|
|
20
|
+
return origin == request.url.origin or origin in trusted_origins
|
|
21
|
+
|
|
22
|
+
# No Origin header: browsers send Sec-Fetch-Site; non-browser clients
|
|
23
|
+
# (curl, server-to-server) send neither and cannot carry ambient cookies
|
|
24
|
+
# in a CSRF-relevant way, so they pass.
|
|
25
|
+
fetch_site = request.headers.get("sec-fetch-site")
|
|
26
|
+
if fetch_site is not None:
|
|
27
|
+
return fetch_site in ("same-origin", "none")
|
|
28
|
+
return True
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Credential policy per NIST SP 800-63B §5.1.1: length is the only rule.
|
|
2
|
+
|
|
3
|
+
At least 8 characters, no composition rules, and a generous upper bound so
|
|
4
|
+
passphrases and password managers are never punished.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
MIN_LENGTH = 8
|
|
10
|
+
MAX_LENGTH = 256
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def password_error(password: object) -> str | None:
|
|
14
|
+
"""None when acceptable, else a human-readable reason."""
|
|
15
|
+
if not isinstance(password, str):
|
|
16
|
+
return "Password must be a string"
|
|
17
|
+
if len(password) < MIN_LENGTH:
|
|
18
|
+
return f"Password must be at least {MIN_LENGTH} characters"
|
|
19
|
+
if len(password) > MAX_LENGTH:
|
|
20
|
+
return f"Password must be at most {MAX_LENGTH} characters"
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def email_error(email: object) -> str | None:
|
|
25
|
+
"""Minimal, standards-agnostic sanity check; real validation is the
|
|
26
|
+
verification email's job (v0.2)."""
|
|
27
|
+
if not isinstance(email, str):
|
|
28
|
+
return "Email must be a string"
|
|
29
|
+
email = email.strip()
|
|
30
|
+
if not 3 <= len(email) <= 254 or "@" not in email[1:-1]:
|
|
31
|
+
return "Email address looks invalid"
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_email(email: str) -> str:
|
|
36
|
+
return email.strip().lower()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""The v0.1 endpoint set (DESIGN §7): email sign-up/sign-in, session
|
|
2
|
+
introspection, sign-out. Paths mirror better-auth's API surface.
|
|
3
|
+
|
|
4
|
+
Every handler is ``(auth, request) -> Response`` over the protocols only —
|
|
5
|
+
no framework object in sight, which is what keeps ``Auth.fetch`` a pure
|
|
6
|
+
function.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from hayate import Headers, Request, Response, problem
|
|
15
|
+
|
|
16
|
+
from . import session as sessions
|
|
17
|
+
from ._uuid7 import new_id
|
|
18
|
+
from .adapter import Where
|
|
19
|
+
from .password import email_error, normalize_email, password_error
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .auth import Auth
|
|
23
|
+
|
|
24
|
+
_GENERIC_SIGNIN_FAILURE = "Invalid email or password"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _json_response(data: Any, status: int = 200, cookies: list[str] | None = None) -> Response:
|
|
28
|
+
headers = Headers({"content-type": "application/json"})
|
|
29
|
+
for cookie in cookies or ():
|
|
30
|
+
headers.append("set-cookie", cookie)
|
|
31
|
+
return Response(json.dumps(data, separators=(",", ":")), status=status, headers=headers)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _read_json_object(request: Request) -> dict[str, Any] | Response:
|
|
35
|
+
try:
|
|
36
|
+
data = await request.json()
|
|
37
|
+
except Exception:
|
|
38
|
+
return problem(400, title="Request body must be JSON")
|
|
39
|
+
if not isinstance(data, dict):
|
|
40
|
+
return problem(400, title="Request body must be a JSON object")
|
|
41
|
+
return data
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def public_user(row: dict[str, Any]) -> dict[str, Any]:
|
|
45
|
+
user = dict(row)
|
|
46
|
+
user["email_verified"] = bool(user.get("email_verified"))
|
|
47
|
+
return user
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _issue_session(auth: Auth, request: Request, user_row: dict[str, Any]) -> Response:
|
|
51
|
+
token, _ = await sessions.create_session(
|
|
52
|
+
auth.adapter,
|
|
53
|
+
user_row["id"],
|
|
54
|
+
ttl=auth.session_ttl,
|
|
55
|
+
user_agent=request.headers.get("user-agent"),
|
|
56
|
+
)
|
|
57
|
+
cookie = sessions.session_cookie(
|
|
58
|
+
token,
|
|
59
|
+
secure=sessions.is_secure_request(request),
|
|
60
|
+
max_age=int(auth.session_ttl.total_seconds()),
|
|
61
|
+
)
|
|
62
|
+
return _json_response({"user": public_user(user_row)}, cookies=[cookie])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def sign_up_email(auth: Auth, request: Request) -> Response:
|
|
66
|
+
data = await _read_json_object(request)
|
|
67
|
+
if isinstance(data, Response):
|
|
68
|
+
return data
|
|
69
|
+
|
|
70
|
+
if (error := email_error(data.get("email"))) is not None:
|
|
71
|
+
return problem(400, title=error)
|
|
72
|
+
if (error := password_error(data.get("password"))) is not None:
|
|
73
|
+
return problem(400, title=error)
|
|
74
|
+
name = data.get("name")
|
|
75
|
+
if name is not None and not isinstance(name, str):
|
|
76
|
+
return problem(400, title="Name must be a string")
|
|
77
|
+
|
|
78
|
+
email = normalize_email(data["email"])
|
|
79
|
+
if await auth.adapter.find_one("user", [Where("email", email)]) is not None:
|
|
80
|
+
return problem(422, title="User already exists")
|
|
81
|
+
|
|
82
|
+
stamp = sessions.isoformat(sessions.now())
|
|
83
|
+
user_row = {
|
|
84
|
+
"id": new_id(),
|
|
85
|
+
"email": email,
|
|
86
|
+
"email_verified": 0,
|
|
87
|
+
"name": name,
|
|
88
|
+
"image": None,
|
|
89
|
+
"created_at": stamp,
|
|
90
|
+
"updated_at": stamp,
|
|
91
|
+
}
|
|
92
|
+
await auth.adapter.create("user", user_row)
|
|
93
|
+
await auth.adapter.create(
|
|
94
|
+
"account",
|
|
95
|
+
{
|
|
96
|
+
"id": new_id(),
|
|
97
|
+
"user_id": user_row["id"],
|
|
98
|
+
"provider_id": "credential",
|
|
99
|
+
"account_id": user_row["id"],
|
|
100
|
+
"password_hash": await auth.crypto.hash_password(data["password"]),
|
|
101
|
+
"access_token": None,
|
|
102
|
+
"refresh_token": None,
|
|
103
|
+
"expires_at": None,
|
|
104
|
+
"created_at": stamp,
|
|
105
|
+
"updated_at": stamp,
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
return await _issue_session(auth, request, user_row)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def sign_in_email(auth: Auth, request: Request) -> Response:
|
|
112
|
+
data = await _read_json_object(request)
|
|
113
|
+
if isinstance(data, Response):
|
|
114
|
+
return data
|
|
115
|
+
email = data.get("email")
|
|
116
|
+
password = data.get("password")
|
|
117
|
+
if not isinstance(email, str) or not isinstance(password, str):
|
|
118
|
+
return problem(400, title="Email and password are required")
|
|
119
|
+
|
|
120
|
+
user_row = await auth.adapter.find_one("user", [Where("email", normalize_email(email))])
|
|
121
|
+
account = None
|
|
122
|
+
if user_row is not None:
|
|
123
|
+
account = await auth.adapter.find_one(
|
|
124
|
+
"account",
|
|
125
|
+
[Where("user_id", user_row["id"]), Where("provider_id", "credential")],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
stored = account["password_hash"] if account else None
|
|
129
|
+
if user_row is None or stored is None:
|
|
130
|
+
# Equalize timing against user enumeration (DESIGN §9): burn the
|
|
131
|
+
# same KDF work a real verification would.
|
|
132
|
+
await auth.crypto.verify_password(password, await auth._dummy_hash())
|
|
133
|
+
return problem(401, title=_GENERIC_SIGNIN_FAILURE)
|
|
134
|
+
|
|
135
|
+
if not await auth.crypto.verify_password(password, stored):
|
|
136
|
+
return problem(401, title=_GENERIC_SIGNIN_FAILURE)
|
|
137
|
+
|
|
138
|
+
return await _issue_session(auth, request, user_row)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def sign_out(auth: Auth, request: Request) -> Response:
|
|
142
|
+
await sessions.revoke_session(auth.adapter, request)
|
|
143
|
+
cookie = sessions.clear_cookie(secure=sessions.is_secure_request(request))
|
|
144
|
+
return _json_response({"success": True}, cookies=[cookie])
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def get_session(auth: Auth, request: Request) -> Response:
|
|
148
|
+
resolved = await auth.get_session(request)
|
|
149
|
+
if resolved is None:
|
|
150
|
+
return _json_response({"session": None, "user": None})
|
|
151
|
+
user, record = resolved
|
|
152
|
+
return _json_response({"session": record, "user": user})
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
ROUTES = {
|
|
156
|
+
("POST", "/sign-up/email"): sign_up_email,
|
|
157
|
+
("POST", "/sign-in/email"): sign_in_email,
|
|
158
|
+
("POST", "/sign-out"): sign_out,
|
|
159
|
+
("GET", "/get-session"): get_session,
|
|
160
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The four models hayate-auth owns (DESIGN §4), and their SQLite DDL.
|
|
2
|
+
|
|
3
|
+
Column names are the single source of truth: adapters validate every
|
|
4
|
+
model/field against this table, which is also what makes the naive SQL
|
|
5
|
+
composition in the sqlite adapter injection-safe.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
MODELS: dict[str, tuple[str, ...]] = {
|
|
11
|
+
"user": ("id", "email", "email_verified", "name", "image", "created_at", "updated_at"),
|
|
12
|
+
"session": (
|
|
13
|
+
"id",
|
|
14
|
+
"token_hash",
|
|
15
|
+
"user_id",
|
|
16
|
+
"expires_at",
|
|
17
|
+
"ip_address",
|
|
18
|
+
"user_agent",
|
|
19
|
+
"created_at",
|
|
20
|
+
),
|
|
21
|
+
"account": (
|
|
22
|
+
"id",
|
|
23
|
+
"user_id",
|
|
24
|
+
"provider_id",
|
|
25
|
+
"account_id",
|
|
26
|
+
"password_hash",
|
|
27
|
+
"access_token",
|
|
28
|
+
"refresh_token",
|
|
29
|
+
"expires_at",
|
|
30
|
+
"created_at",
|
|
31
|
+
"updated_at",
|
|
32
|
+
),
|
|
33
|
+
"verification": ("id", "identifier", "value_hash", "expires_at", "created_at"),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
SQLITE_SCHEMA = """\
|
|
37
|
+
CREATE TABLE IF NOT EXISTS "user" (
|
|
38
|
+
id TEXT PRIMARY KEY,
|
|
39
|
+
email TEXT NOT NULL UNIQUE,
|
|
40
|
+
email_verified INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
name TEXT,
|
|
42
|
+
image TEXT,
|
|
43
|
+
created_at TEXT NOT NULL,
|
|
44
|
+
updated_at TEXT NOT NULL
|
|
45
|
+
);
|
|
46
|
+
CREATE TABLE IF NOT EXISTS "session" (
|
|
47
|
+
id TEXT PRIMARY KEY,
|
|
48
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
49
|
+
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
50
|
+
expires_at TEXT NOT NULL,
|
|
51
|
+
ip_address TEXT,
|
|
52
|
+
user_agent TEXT,
|
|
53
|
+
created_at TEXT NOT NULL
|
|
54
|
+
);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS session_user_id ON "session"(user_id);
|
|
56
|
+
CREATE TABLE IF NOT EXISTS "account" (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
59
|
+
provider_id TEXT NOT NULL,
|
|
60
|
+
account_id TEXT NOT NULL,
|
|
61
|
+
password_hash TEXT,
|
|
62
|
+
access_token TEXT,
|
|
63
|
+
refresh_token TEXT,
|
|
64
|
+
expires_at TEXT,
|
|
65
|
+
created_at TEXT NOT NULL,
|
|
66
|
+
updated_at TEXT NOT NULL,
|
|
67
|
+
UNIQUE (provider_id, account_id)
|
|
68
|
+
);
|
|
69
|
+
CREATE INDEX IF NOT EXISTS account_user_id ON "account"(user_id);
|
|
70
|
+
CREATE TABLE IF NOT EXISTS "verification" (
|
|
71
|
+
id TEXT PRIMARY KEY,
|
|
72
|
+
identifier TEXT NOT NULL,
|
|
73
|
+
value_hash TEXT NOT NULL,
|
|
74
|
+
expires_at TEXT NOT NULL,
|
|
75
|
+
created_at TEXT NOT NULL
|
|
76
|
+
);
|
|
77
|
+
"""
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Opaque session tokens (DESIGN §6).
|
|
2
|
+
|
|
3
|
+
The cookie carries ``secrets.token_urlsafe(32)``; the database stores only
|
|
4
|
+
its SHA-256, so a leaked database cannot impersonate anyone. Cookie name is
|
|
5
|
+
``__Host-hayate_auth.session`` on HTTPS and falls back to the bare name for
|
|
6
|
+
local plain-HTTP development.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import secrets
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from hayate import Request
|
|
17
|
+
from hayate.cookies import parse_cookies, serialize_set_cookie
|
|
18
|
+
|
|
19
|
+
from ._uuid7 import new_id
|
|
20
|
+
from .adapter import Adapter, Where
|
|
21
|
+
|
|
22
|
+
COOKIE_BASE = "hayate_auth.session"
|
|
23
|
+
HOST_COOKIE = f"__Host-{COOKIE_BASE}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def now() -> datetime:
|
|
27
|
+
return datetime.now(UTC)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def isoformat(moment: datetime) -> str:
|
|
31
|
+
return moment.isoformat(timespec="seconds")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def new_token() -> str:
|
|
35
|
+
return secrets.token_urlsafe(32)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def token_hash(token: str) -> str:
|
|
39
|
+
return hashlib.sha256(token.encode("ascii")).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_secure_request(request: Request) -> bool:
|
|
43
|
+
return request.url.protocol == "https:"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_token(request: Request) -> str | None:
|
|
47
|
+
header = request.headers.get("cookie")
|
|
48
|
+
if header is None:
|
|
49
|
+
return None
|
|
50
|
+
cookies = parse_cookies(header)
|
|
51
|
+
return cookies.get(HOST_COOKIE) or cookies.get(COOKIE_BASE)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def session_cookie(token: str, *, secure: bool, max_age: int) -> str:
|
|
55
|
+
return serialize_set_cookie(
|
|
56
|
+
HOST_COOKIE if secure else COOKIE_BASE,
|
|
57
|
+
token,
|
|
58
|
+
max_age=max_age,
|
|
59
|
+
path="/",
|
|
60
|
+
secure=secure,
|
|
61
|
+
http_only=True,
|
|
62
|
+
same_site="lax",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def clear_cookie(*, secure: bool) -> str:
|
|
67
|
+
return serialize_set_cookie(
|
|
68
|
+
HOST_COOKIE if secure else COOKIE_BASE,
|
|
69
|
+
"",
|
|
70
|
+
max_age=0,
|
|
71
|
+
path="/",
|
|
72
|
+
secure=secure,
|
|
73
|
+
http_only=True,
|
|
74
|
+
same_site="lax",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def create_session(
|
|
79
|
+
adapter: Adapter, user_id: str, *, ttl: timedelta, user_agent: str | None
|
|
80
|
+
) -> tuple[str, dict[str, Any]]:
|
|
81
|
+
"""Insert a session row and return (cookie token, public row)."""
|
|
82
|
+
token = new_token()
|
|
83
|
+
record = {
|
|
84
|
+
"id": new_id(),
|
|
85
|
+
"token_hash": token_hash(token),
|
|
86
|
+
"user_id": user_id,
|
|
87
|
+
"expires_at": isoformat(now() + ttl),
|
|
88
|
+
"ip_address": None,
|
|
89
|
+
"user_agent": user_agent,
|
|
90
|
+
"created_at": isoformat(now()),
|
|
91
|
+
}
|
|
92
|
+
await adapter.create("session", record)
|
|
93
|
+
return token, record
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def resolve_session(adapter: Adapter, request: Request) -> dict[str, Any] | None:
|
|
97
|
+
"""The session row for the request's cookie, or None. Expired rows are
|
|
98
|
+
deleted on sight."""
|
|
99
|
+
token = read_token(request)
|
|
100
|
+
if token is None:
|
|
101
|
+
return None
|
|
102
|
+
record = await adapter.find_one("session", [Where("token_hash", token_hash(token))])
|
|
103
|
+
if record is None:
|
|
104
|
+
return None
|
|
105
|
+
if record["expires_at"] <= isoformat(now()):
|
|
106
|
+
await adapter.delete("session", [Where("id", record["id"])])
|
|
107
|
+
return None
|
|
108
|
+
return record
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def revoke_session(adapter: Adapter, request: Request) -> None:
|
|
112
|
+
token = read_token(request)
|
|
113
|
+
if token is not None:
|
|
114
|
+
await adapter.delete("session", [Where("token_hash", token_hash(token))])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def public_session(record: dict[str, Any]) -> dict[str, Any]:
|
|
118
|
+
"""The wire shape: everything except the token hash."""
|
|
119
|
+
return {key: value for key, value in record.items() if key != "token_hash"}
|