better-auth-server 0.1.0__py3-none-any.whl
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.
- better_auth/__init__.py +40 -0
- better_auth/adapters/__init__.py +4 -0
- better_auth/adapters/base.py +44 -0
- better_auth/adapters/memory.py +55 -0
- better_auth/adapters/sqlalchemy.py +166 -0
- better_auth/auth.py +239 -0
- better_auth/config.py +55 -0
- better_auth/crypto.py +91 -0
- better_auth/endpoints.py +582 -0
- better_auth/integrations/__init__.py +0 -0
- better_auth/integrations/fastapi.py +85 -0
- better_auth/oauth.py +384 -0
- better_auth/plugins.py +31 -0
- better_auth/py.typed +0 -0
- better_auth/schema.py +75 -0
- better_auth/session.py +141 -0
- better_auth/types.py +112 -0
- better_auth_server-0.1.0.dist-info/METADATA +247 -0
- better_auth_server-0.1.0.dist-info/RECORD +21 -0
- better_auth_server-0.1.0.dist-info/WHEEL +4 -0
- better_auth_server-0.1.0.dist-info/licenses/LICENSE +21 -0
better_auth/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""better-auth-py: framework-agnostic authentication for Python.
|
|
2
|
+
|
|
3
|
+
A port of `better-auth <https://better-auth.com>`_ with a FastAPI integration.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .adapters import BaseAdapter, MemoryAdapter, Where
|
|
7
|
+
from .auth import BetterAuth
|
|
8
|
+
from .config import EmailAndPassword, EmailVerification, RateLimit, SessionOptions
|
|
9
|
+
from .oauth import Discord, GitHub, Google, OAuthProvider, OAuthTokens, OAuthUserInfo
|
|
10
|
+
from .plugins import Plugin
|
|
11
|
+
from .schema import CORE_SCHEMA, Field, Schema
|
|
12
|
+
from .types import APIError, AuthRequest, AuthResponse, Ctx
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"CORE_SCHEMA",
|
|
18
|
+
"APIError",
|
|
19
|
+
"AuthRequest",
|
|
20
|
+
"AuthResponse",
|
|
21
|
+
"BaseAdapter",
|
|
22
|
+
"BetterAuth",
|
|
23
|
+
"Ctx",
|
|
24
|
+
"Discord",
|
|
25
|
+
"EmailAndPassword",
|
|
26
|
+
"EmailVerification",
|
|
27
|
+
"Field",
|
|
28
|
+
"GitHub",
|
|
29
|
+
"Google",
|
|
30
|
+
"MemoryAdapter",
|
|
31
|
+
"OAuthProvider",
|
|
32
|
+
"OAuthTokens",
|
|
33
|
+
"OAuthUserInfo",
|
|
34
|
+
"Plugin",
|
|
35
|
+
"RateLimit",
|
|
36
|
+
"Schema",
|
|
37
|
+
"SessionOptions",
|
|
38
|
+
"Where",
|
|
39
|
+
"__version__",
|
|
40
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Database adapter interface: generic CRUD over named models, like better-auth adapters.
|
|
2
|
+
|
|
3
|
+
Rows are plain dicts with camelCase keys matching better_auth.schema. Plugins can define
|
|
4
|
+
their own models without adapter changes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..schema import Schema
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Where:
|
|
15
|
+
"""A single condition. operator: eq | ne | in | contains | gt | gte | lt | lte."""
|
|
16
|
+
|
|
17
|
+
__slots__ = ("field", "operator", "value")
|
|
18
|
+
|
|
19
|
+
def __init__(self, field: str, value: Any, operator: str = "eq"):
|
|
20
|
+
self.field = field
|
|
21
|
+
self.value = value
|
|
22
|
+
self.operator = operator
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BaseAdapter:
|
|
26
|
+
def init(self, schema: Schema) -> None:
|
|
27
|
+
"""Called once by BetterAuth with the merged (core + plugins) schema."""
|
|
28
|
+
|
|
29
|
+
async def create(self, model: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
async def find_one(self, model: str, where: list[Where]) -> dict[str, Any] | None:
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
async def find_many(self, model: str, where: list[Where] | None = None) -> list[dict[str, Any]]:
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
async def update(
|
|
39
|
+
self, model: str, where: list[Where], data: dict[str, Any]
|
|
40
|
+
) -> dict[str, Any] | None:
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
async def delete_many(self, model: str, where: list[Where]) -> int:
|
|
44
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""In-memory adapter for development and tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .base import BaseAdapter, Where
|
|
10
|
+
|
|
11
|
+
_OPS: dict[str, Callable[[Any, Any], bool]] = {
|
|
12
|
+
"eq": lambda a, b: a == b,
|
|
13
|
+
"ne": lambda a, b: a != b,
|
|
14
|
+
"in": lambda a, b: a in b,
|
|
15
|
+
"contains": lambda a, b: isinstance(a, str) and b in a,
|
|
16
|
+
"gt": lambda a, b: a is not None and a > b,
|
|
17
|
+
"gte": lambda a, b: a is not None and a >= b,
|
|
18
|
+
"lt": lambda a, b: a is not None and a < b,
|
|
19
|
+
"lte": lambda a, b: a is not None and a <= b,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _matches(row: dict[str, Any], where: list[Where] | None) -> bool:
|
|
24
|
+
return all(_OPS[c.operator](row.get(c.field), c.value) for c in where or [])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MemoryAdapter(BaseAdapter):
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._store: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
30
|
+
|
|
31
|
+
async def create(self, model: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
32
|
+
self._store[model].append(dict(data))
|
|
33
|
+
return dict(data)
|
|
34
|
+
|
|
35
|
+
async def find_one(self, model: str, where: list[Where]) -> dict[str, Any] | None:
|
|
36
|
+
return next((dict(r) for r in self._store[model] if _matches(r, where)), None)
|
|
37
|
+
|
|
38
|
+
async def find_many(self, model: str, where: list[Where] | None = None) -> list[dict[str, Any]]:
|
|
39
|
+
return [dict(r) for r in self._store[model] if _matches(r, where)]
|
|
40
|
+
|
|
41
|
+
async def update(
|
|
42
|
+
self, model: str, where: list[Where], data: dict[str, Any]
|
|
43
|
+
) -> dict[str, Any] | None:
|
|
44
|
+
for row in self._store[model]:
|
|
45
|
+
if _matches(row, where):
|
|
46
|
+
row.update(data)
|
|
47
|
+
return dict(row)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
async def delete_many(self, model: str, where: list[Where]) -> int:
|
|
51
|
+
rows = self._store[model]
|
|
52
|
+
kept = [r for r in rows if not _matches(r, where)]
|
|
53
|
+
deleted = len(rows) - len(kept)
|
|
54
|
+
self._store[model] = kept
|
|
55
|
+
return deleted
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""SQLAlchemy adapter (async). Tables are generated from the merged schema.
|
|
2
|
+
|
|
3
|
+
Install with: ``pip install better-auth-py[sqlalchemy]``.
|
|
4
|
+
|
|
5
|
+
Datetimes are stored as naive UTC (portable across SQLite/Postgres/MySQL) and always
|
|
6
|
+
returned timezone-aware (UTC).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, MetaData, String, Table, Text, and_
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
16
|
+
from sqlalchemy.sql import ColumnElement
|
|
17
|
+
|
|
18
|
+
from ..schema import Schema
|
|
19
|
+
from .base import BaseAdapter, Where
|
|
20
|
+
|
|
21
|
+
_TYPES = {
|
|
22
|
+
"string": lambda: String(255),
|
|
23
|
+
"text": lambda: Text(),
|
|
24
|
+
"boolean": lambda: Boolean(),
|
|
25
|
+
"datetime": lambda: DateTime(),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _to_storage(value: Any) -> Any:
|
|
30
|
+
if isinstance(value, datetime) and value.tzinfo is not None:
|
|
31
|
+
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _from_storage(value: Any) -> Any:
|
|
36
|
+
if isinstance(value, datetime) and value.tzinfo is None:
|
|
37
|
+
return value.replace(tzinfo=timezone.utc)
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SQLAlchemyAdapter(BaseAdapter):
|
|
42
|
+
def __init__(self, engine: AsyncEngine, metadata: MetaData | None = None):
|
|
43
|
+
self.engine = engine
|
|
44
|
+
self.metadata = metadata or MetaData()
|
|
45
|
+
self._tables: dict[str, Table] = {}
|
|
46
|
+
|
|
47
|
+
def init(self, schema: Schema) -> None:
|
|
48
|
+
for model, fields in schema.items():
|
|
49
|
+
if model in self._tables:
|
|
50
|
+
continue
|
|
51
|
+
columns: list[Column] = []
|
|
52
|
+
for name, spec in fields.items():
|
|
53
|
+
args: list[Any] = [_TYPES[spec.type]()]
|
|
54
|
+
if spec.references:
|
|
55
|
+
ref_model, ref_col = spec.references.split(".")
|
|
56
|
+
args.append(ForeignKey(f"{ref_model}.{ref_col}"))
|
|
57
|
+
columns.append(
|
|
58
|
+
Column(
|
|
59
|
+
name,
|
|
60
|
+
*args,
|
|
61
|
+
primary_key=name == "id",
|
|
62
|
+
nullable=not spec.required,
|
|
63
|
+
unique=spec.unique and name != "id",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
self._tables[model] = Table(model, self.metadata, *columns)
|
|
67
|
+
|
|
68
|
+
async def create_tables(self) -> None:
|
|
69
|
+
"""Create missing tables (dev convenience — use real migrations in production)."""
|
|
70
|
+
async with self.engine.begin() as conn:
|
|
71
|
+
await conn.run_sync(self.metadata.create_all)
|
|
72
|
+
|
|
73
|
+
def _table(self, model: str) -> Table:
|
|
74
|
+
try:
|
|
75
|
+
return self._tables[model]
|
|
76
|
+
except KeyError:
|
|
77
|
+
raise KeyError(f"Unknown model {model!r} — was the adapter initialized?") from None
|
|
78
|
+
|
|
79
|
+
def _condition(self, table: Table, where: list[Where] | None) -> ColumnElement | None:
|
|
80
|
+
if not where:
|
|
81
|
+
return None
|
|
82
|
+
parts = []
|
|
83
|
+
for c in where:
|
|
84
|
+
col = table.c[c.field]
|
|
85
|
+
value = _to_storage(c.value)
|
|
86
|
+
if c.operator == "eq":
|
|
87
|
+
parts.append(col == value)
|
|
88
|
+
elif c.operator == "ne":
|
|
89
|
+
parts.append(col != value)
|
|
90
|
+
elif c.operator == "in":
|
|
91
|
+
parts.append(col.in_([_to_storage(v) for v in c.value]))
|
|
92
|
+
elif c.operator == "contains":
|
|
93
|
+
parts.append(col.contains(c.value))
|
|
94
|
+
elif c.operator == "gt":
|
|
95
|
+
parts.append(col > value)
|
|
96
|
+
elif c.operator == "gte":
|
|
97
|
+
parts.append(col >= value)
|
|
98
|
+
elif c.operator == "lt":
|
|
99
|
+
parts.append(col < value)
|
|
100
|
+
elif c.operator == "lte":
|
|
101
|
+
parts.append(col <= value)
|
|
102
|
+
else:
|
|
103
|
+
raise ValueError(f"Unsupported operator {c.operator!r}")
|
|
104
|
+
return and_(*parts)
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _row(row: Any) -> dict[str, Any]:
|
|
108
|
+
return {key: _from_storage(value) for key, value in row._mapping.items()}
|
|
109
|
+
|
|
110
|
+
async def create(self, model: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
111
|
+
table = self._table(model)
|
|
112
|
+
values = {k: _to_storage(v) for k, v in data.items()}
|
|
113
|
+
async with self.engine.begin() as conn:
|
|
114
|
+
await conn.execute(table.insert().values(**values))
|
|
115
|
+
return dict(data)
|
|
116
|
+
|
|
117
|
+
async def find_one(self, model: str, where: list[Where]) -> dict[str, Any] | None:
|
|
118
|
+
table = self._table(model)
|
|
119
|
+
stmt = table.select().limit(1)
|
|
120
|
+
cond = self._condition(table, where)
|
|
121
|
+
if cond is not None:
|
|
122
|
+
stmt = stmt.where(cond)
|
|
123
|
+
async with self.engine.connect() as conn:
|
|
124
|
+
row = (await conn.execute(stmt)).first()
|
|
125
|
+
return self._row(row) if row is not None else None
|
|
126
|
+
|
|
127
|
+
async def find_many(self, model: str, where: list[Where] | None = None) -> list[dict[str, Any]]:
|
|
128
|
+
table = self._table(model)
|
|
129
|
+
stmt = table.select()
|
|
130
|
+
cond = self._condition(table, where)
|
|
131
|
+
if cond is not None:
|
|
132
|
+
stmt = stmt.where(cond)
|
|
133
|
+
async with self.engine.connect() as conn:
|
|
134
|
+
rows = (await conn.execute(stmt)).all()
|
|
135
|
+
return [self._row(r) for r in rows]
|
|
136
|
+
|
|
137
|
+
async def update(
|
|
138
|
+
self, model: str, where: list[Where], data: dict[str, Any]
|
|
139
|
+
) -> dict[str, Any] | None:
|
|
140
|
+
table = self._table(model)
|
|
141
|
+
cond = self._condition(table, where)
|
|
142
|
+
values = {k: _to_storage(v) for k, v in data.items()}
|
|
143
|
+
stmt = table.update().values(**values)
|
|
144
|
+
if cond is not None:
|
|
145
|
+
stmt = stmt.where(cond)
|
|
146
|
+
async with self.engine.begin() as conn:
|
|
147
|
+
await conn.execute(stmt)
|
|
148
|
+
return await self.find_one(model, self._refind(where, data))
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _refind(where: list[Where], data: dict[str, Any]) -> list[Where]:
|
|
152
|
+
# if an updated column was part of the lookup, look it up by its new value
|
|
153
|
+
return [
|
|
154
|
+
Where(c.field, data[c.field]) if c.operator == "eq" and c.field in data else c
|
|
155
|
+
for c in where
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
async def delete_many(self, model: str, where: list[Where]) -> int:
|
|
159
|
+
table = self._table(model)
|
|
160
|
+
cond = self._condition(table, where)
|
|
161
|
+
stmt = table.delete()
|
|
162
|
+
if cond is not None:
|
|
163
|
+
stmt = stmt.where(cond)
|
|
164
|
+
async with self.engine.begin() as conn:
|
|
165
|
+
result = await conn.execute(stmt)
|
|
166
|
+
return int(result.rowcount or 0)
|
better_auth/auth.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""BetterAuth: configuration, routing and request dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .adapters.base import BaseAdapter
|
|
14
|
+
from .adapters.memory import MemoryAdapter
|
|
15
|
+
from .config import EmailAndPassword, EmailVerification, RateLimit, SessionOptions
|
|
16
|
+
from .endpoints import ROUTES
|
|
17
|
+
from .oauth import OAuthProvider
|
|
18
|
+
from .plugins import Plugin
|
|
19
|
+
from .schema import CORE_SCHEMA, Schema, merge_schema
|
|
20
|
+
from .session import get_session as _get_session
|
|
21
|
+
from .types import APIError, AuthRequest, AuthResponse, Ctx
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("better_auth")
|
|
24
|
+
|
|
25
|
+
# better-auth's default special rate-limit rules: (window seconds, max requests)
|
|
26
|
+
_SPECIAL_RATE_RULES: list[tuple[Callable[[str], bool], tuple[int, int]]] = [
|
|
27
|
+
(
|
|
28
|
+
lambda p: p.startswith(("/sign-in", "/sign-up", "/change-password", "/change-email")),
|
|
29
|
+
(10, 3),
|
|
30
|
+
),
|
|
31
|
+
(
|
|
32
|
+
lambda p: (
|
|
33
|
+
p in ("/request-password-reset", "/send-verification-email")
|
|
34
|
+
or p.startswith("/forget-password")
|
|
35
|
+
),
|
|
36
|
+
(60, 3),
|
|
37
|
+
),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BetterAuth:
|
|
42
|
+
"""The auth instance. Mount it with an integration (e.g. better_auth.integrations.fastapi)
|
|
43
|
+
or call :meth:`handle` with an :class:`~better_auth.types.AuthRequest` directly."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
secret: str,
|
|
49
|
+
adapter: BaseAdapter | None = None,
|
|
50
|
+
base_url: str = "http://localhost:8000",
|
|
51
|
+
base_path: str = "/api/auth",
|
|
52
|
+
email_and_password: EmailAndPassword | None = None,
|
|
53
|
+
email_verification: EmailVerification | None = None,
|
|
54
|
+
social_providers: Mapping[str, OAuthProvider] | None = None,
|
|
55
|
+
session: SessionOptions | None = None,
|
|
56
|
+
rate_limit: RateLimit | None = None,
|
|
57
|
+
trusted_origins: list[str] | None = None,
|
|
58
|
+
plugins: list[Plugin] | None = None,
|
|
59
|
+
hooks: dict[str, Callable[..., Awaitable[None]]] | None = None,
|
|
60
|
+
http_client: httpx.AsyncClient | None = None,
|
|
61
|
+
cookie_prefix: str = "better-auth",
|
|
62
|
+
use_secure_cookies: bool | None = None,
|
|
63
|
+
skip_state_cookie_check: bool = False,
|
|
64
|
+
):
|
|
65
|
+
if not secret or len(secret) < 32:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"secret must be at least 32 characters — generate one with"
|
|
68
|
+
" `openssl rand -base64 32`"
|
|
69
|
+
)
|
|
70
|
+
self.secret = secret
|
|
71
|
+
self.base_url = base_url.rstrip("/")
|
|
72
|
+
stripped = base_path.strip("/")
|
|
73
|
+
self.base_path = f"/{stripped}" if stripped else ""
|
|
74
|
+
self.email_and_password = email_and_password or EmailAndPassword()
|
|
75
|
+
self.email_verification = email_verification or EmailVerification()
|
|
76
|
+
self.session_options = session or SessionOptions()
|
|
77
|
+
self.rate_limit = rate_limit or RateLimit()
|
|
78
|
+
self.trusted_origins = [origin.rstrip("/") for origin in trusted_origins or []]
|
|
79
|
+
self.plugins = list(plugins or [])
|
|
80
|
+
self.hooks = dict(hooks or {})
|
|
81
|
+
self.cookie_prefix = cookie_prefix
|
|
82
|
+
self.use_secure_cookies = (
|
|
83
|
+
use_secure_cookies
|
|
84
|
+
if use_secure_cookies is not None
|
|
85
|
+
else self.base_url.startswith("https://")
|
|
86
|
+
)
|
|
87
|
+
self.skip_state_cookie_check = skip_state_cookie_check
|
|
88
|
+
|
|
89
|
+
self.social_providers = dict(social_providers or {})
|
|
90
|
+
for provider_id, provider in self.social_providers.items():
|
|
91
|
+
if not provider.provider_id:
|
|
92
|
+
provider.provider_id = provider_id
|
|
93
|
+
|
|
94
|
+
self.schema: Schema = merge_schema(CORE_SCHEMA, *(p.schema for p in self.plugins))
|
|
95
|
+
self.adapter = adapter if adapter is not None else MemoryAdapter()
|
|
96
|
+
self.adapter.init(self.schema)
|
|
97
|
+
|
|
98
|
+
self._http = http_client
|
|
99
|
+
self._rate_buckets: dict[str, tuple[float, int]] = {}
|
|
100
|
+
self._routes: list[tuple[str, tuple[str, ...], Any]] = []
|
|
101
|
+
for method, path, handler in [
|
|
102
|
+
*ROUTES,
|
|
103
|
+
*(route for plugin in self.plugins for route in plugin.routes()),
|
|
104
|
+
]:
|
|
105
|
+
self._routes.append((method, tuple(path.strip("/").split("/")), handler))
|
|
106
|
+
|
|
107
|
+
# --- helpers ----------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def http(self) -> httpx.AsyncClient:
|
|
111
|
+
if self._http is None:
|
|
112
|
+
self._http = httpx.AsyncClient(timeout=10)
|
|
113
|
+
return self._http
|
|
114
|
+
|
|
115
|
+
async def run_hook(self, name: str, *args: Any) -> None:
|
|
116
|
+
hook = self.hooks.get(name)
|
|
117
|
+
if hook is not None:
|
|
118
|
+
await hook(*args)
|
|
119
|
+
|
|
120
|
+
def _allowed_origins(self) -> set[str]:
|
|
121
|
+
origins = {self._origin(self.base_url)}
|
|
122
|
+
origins.update(self._origin(origin) for origin in self.trusted_origins)
|
|
123
|
+
origins.discard("")
|
|
124
|
+
return origins
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _origin(url: str) -> str:
|
|
128
|
+
parts = urlsplit(url)
|
|
129
|
+
return f"{parts.scheme}://{parts.netloc}" if parts.scheme and parts.netloc else ""
|
|
130
|
+
|
|
131
|
+
def is_trusted_url(self, url: str) -> bool:
|
|
132
|
+
"""Relative paths and URLs on the base/trusted origins are allowed redirect targets."""
|
|
133
|
+
if url.startswith("/") and not url.startswith("//"):
|
|
134
|
+
return True
|
|
135
|
+
return self._origin(url) in self._allowed_origins()
|
|
136
|
+
|
|
137
|
+
def ensure_trusted_url(self, url: str) -> None:
|
|
138
|
+
if not self.is_trusted_url(url):
|
|
139
|
+
raise APIError(403, "INVALID_CALLBACK_URL", "Callback URL is not trusted")
|
|
140
|
+
|
|
141
|
+
async def load_session(self, request: AuthRequest) -> dict[str, Any] | None:
|
|
142
|
+
"""``{"session": ..., "user": ...}`` for the request, or None. Used by integrations."""
|
|
143
|
+
result, _cookies = await _get_session(self, request)
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
# --- dispatch ---------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
async def handle(self, request: AuthRequest) -> AuthResponse:
|
|
149
|
+
try:
|
|
150
|
+
return await self._dispatch(request)
|
|
151
|
+
except APIError as error:
|
|
152
|
+
return AuthResponse(
|
|
153
|
+
status=error.status, body={"code": error.code, "message": error.message}
|
|
154
|
+
)
|
|
155
|
+
except Exception:
|
|
156
|
+
logger.exception("better-auth error handling %s %s", request.method, request.path)
|
|
157
|
+
return AuthResponse(status=500, body={"message": "Internal Server Error"})
|
|
158
|
+
|
|
159
|
+
async def _dispatch(self, request: AuthRequest) -> AuthResponse:
|
|
160
|
+
request.path = "/" + request.path.strip("/")
|
|
161
|
+
|
|
162
|
+
retry_after = self._check_rate_limit(request)
|
|
163
|
+
if retry_after is not None:
|
|
164
|
+
return AuthResponse(
|
|
165
|
+
status=429,
|
|
166
|
+
body={"message": "Too many requests. Please try again later."},
|
|
167
|
+
headers=[("x-retry-after", str(retry_after))],
|
|
168
|
+
)
|
|
169
|
+
self._check_origin(request)
|
|
170
|
+
|
|
171
|
+
match = self._match(request.method, request.path)
|
|
172
|
+
if match is None:
|
|
173
|
+
return AuthResponse(status=404, body={"message": "Not Found"})
|
|
174
|
+
handler, params = match
|
|
175
|
+
|
|
176
|
+
ctx = Ctx(auth=self, request=request, params=params)
|
|
177
|
+
for plugin in self.plugins:
|
|
178
|
+
short_circuit = await plugin.before(ctx)
|
|
179
|
+
if short_circuit is not None:
|
|
180
|
+
return short_circuit
|
|
181
|
+
|
|
182
|
+
result = await handler(ctx)
|
|
183
|
+
response = result if isinstance(result, AuthResponse) else AuthResponse(body=result)
|
|
184
|
+
|
|
185
|
+
for plugin in self.plugins:
|
|
186
|
+
replacement = await plugin.after(ctx, response)
|
|
187
|
+
if replacement is not None:
|
|
188
|
+
response = replacement
|
|
189
|
+
return response
|
|
190
|
+
|
|
191
|
+
def _match(self, method: str, path: str) -> tuple[Any, dict[str, str]] | None:
|
|
192
|
+
parts = tuple(path.strip("/").split("/"))
|
|
193
|
+
for route_method, segments, handler in self._routes:
|
|
194
|
+
if route_method != method or len(segments) != len(parts):
|
|
195
|
+
continue
|
|
196
|
+
params: dict[str, str] = {}
|
|
197
|
+
for segment, part in zip(segments, parts, strict=True):
|
|
198
|
+
if segment.startswith("{") and segment.endswith("}"):
|
|
199
|
+
params[segment[1:-1]] = part
|
|
200
|
+
elif segment != part:
|
|
201
|
+
break
|
|
202
|
+
else:
|
|
203
|
+
return handler, params
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
def _check_origin(self, request: AuthRequest) -> None:
|
|
207
|
+
"""Reject state-changing requests from untrusted browser origins (CSRF)."""
|
|
208
|
+
if request.method == "GET":
|
|
209
|
+
return
|
|
210
|
+
origin = request.headers.get("origin")
|
|
211
|
+
if not origin: # non-browser clients (no Origin header) pass through
|
|
212
|
+
return
|
|
213
|
+
if origin.rstrip("/") not in self._allowed_origins():
|
|
214
|
+
raise APIError(403, "INVALID_ORIGIN", "Origin not trusted")
|
|
215
|
+
|
|
216
|
+
def _check_rate_limit(self, request: AuthRequest) -> int | None:
|
|
217
|
+
"""Returns seconds to wait when limited, else None. Fixed window, in-memory."""
|
|
218
|
+
limit = self.rate_limit
|
|
219
|
+
if not limit.enabled:
|
|
220
|
+
return None
|
|
221
|
+
window, maximum = limit.window, limit.max
|
|
222
|
+
for matches, rule in _SPECIAL_RATE_RULES:
|
|
223
|
+
if matches(request.path):
|
|
224
|
+
window, maximum = rule
|
|
225
|
+
break
|
|
226
|
+
custom = limit.custom_rules.get(request.path)
|
|
227
|
+
if custom is not None:
|
|
228
|
+
window, maximum = custom
|
|
229
|
+
|
|
230
|
+
key = f"{request.client_ip or 'no-ip'}-{request.path}"
|
|
231
|
+
now = time.time()
|
|
232
|
+
start, count = self._rate_buckets.get(key, (now, 0))
|
|
233
|
+
if now - start >= window:
|
|
234
|
+
start, count = now, 0
|
|
235
|
+
count += 1
|
|
236
|
+
self._rate_buckets[key] = (start, count)
|
|
237
|
+
if count > maximum:
|
|
238
|
+
return max(1, int(window - (now - start)))
|
|
239
|
+
return None
|
better_auth/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Configuration dataclasses, mirroring better-auth's options."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
#: async callback(user, url, token) used for verification / reset-password emails.
|
|
10
|
+
SendEmail = Callable[[dict[str, Any], str, str], Awaitable[None]]
|
|
11
|
+
|
|
12
|
+
DAY = 60 * 60 * 24
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class EmailAndPassword:
|
|
17
|
+
enabled: bool = False
|
|
18
|
+
min_password_length: int = 8
|
|
19
|
+
max_password_length: int = 128
|
|
20
|
+
require_email_verification: bool = False
|
|
21
|
+
#: create a session right after sign-up (ignored when email verification is required)
|
|
22
|
+
auto_sign_in: bool = True
|
|
23
|
+
send_reset_password: SendEmail | None = None
|
|
24
|
+
reset_password_token_expires_in: int = 60 * 60
|
|
25
|
+
revoke_sessions_on_password_reset: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class EmailVerification:
|
|
30
|
+
send_verification_email: SendEmail | None = None
|
|
31
|
+
send_on_sign_up: bool = False
|
|
32
|
+
auto_sign_in_after_verification: bool = False
|
|
33
|
+
expires_in: int = 60 * 60
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class SessionOptions:
|
|
38
|
+
expires_in: int = 7 * DAY
|
|
39
|
+
#: refresh `expiresAt` when the session is older than this
|
|
40
|
+
update_age: int = 1 * DAY
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class RateLimit:
|
|
45
|
+
"""Fixed-window in-memory rate limiting, keyed by client IP + path.
|
|
46
|
+
|
|
47
|
+
ponytail: in-memory only — use one worker or put stricter limits at the proxy;
|
|
48
|
+
a shared-storage backend can come later if needed.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
enabled: bool = False
|
|
52
|
+
window: int = 10 # seconds
|
|
53
|
+
max: int = 100
|
|
54
|
+
#: per-path overrides, e.g. {"/sign-in/email": (10, 3)}
|
|
55
|
+
custom_rules: dict[str, tuple[int, int]] = field(default_factory=dict)
|