fastauth-py 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.
- fastauth/__init__.py +9 -0
- fastauth/cli/__init__.py +3 -0
- fastauth/cli/main.py +207 -0
- fastauth/config.py +270 -0
- fastauth/domain/__init__.py +0 -0
- fastauth/domain/enums.py +125 -0
- fastauth/domain/events.py +217 -0
- fastauth/domain/models.py +238 -0
- fastauth/exceptions.py +191 -0
- fastauth/flows/__init__.py +3 -0
- fastauth/flows/change_email.py +203 -0
- fastauth/flows/change_password.py +75 -0
- fastauth/flows/credentials.py +337 -0
- fastauth/flows/email_otp.py +818 -0
- fastauth/flows/password_reset.py +163 -0
- fastauth/flows/refresh.py +63 -0
- fastauth/flows/sessions.py +116 -0
- fastauth/flows/user_management.py +304 -0
- fastauth/flows/verification.py +147 -0
- fastauth/messaging/__init__.py +0 -0
- fastauth/messaging/email.py +60 -0
- fastauth/messaging/templates/delete_account.html +10 -0
- fastauth/messaging/templates/delete_account.txt +8 -0
- fastauth/messaging/templates/otp_email_change.html +10 -0
- fastauth/messaging/templates/otp_email_change.txt +7 -0
- fastauth/messaging/templates/otp_password_reset.html +10 -0
- fastauth/messaging/templates/otp_password_reset.txt +7 -0
- fastauth/messaging/templates/otp_sign_in.html +10 -0
- fastauth/messaging/templates/otp_sign_in.txt +7 -0
- fastauth/messaging/templates/otp_verification.html +10 -0
- fastauth/messaging/templates/otp_verification.txt +7 -0
- fastauth/messaging/templates/reset.html +9 -0
- fastauth/messaging/templates/reset.txt +6 -0
- fastauth/messaging/templates/verification.html +9 -0
- fastauth/messaging/templates/verification.txt +6 -0
- fastauth/plugins/__init__.py +3 -0
- fastauth/plugins/api_key.py +433 -0
- fastauth/plugins/audit_logs.py +195 -0
- fastauth/plugins/base.py +210 -0
- fastauth/plugins/email_otp.py +336 -0
- fastauth/plugins/jwt.py +212 -0
- fastauth/plugins/openapi.py +137 -0
- fastauth/plugins/test_utils.py +137 -0
- fastauth/py.typed +0 -0
- fastauth/runtime/__init__.py +0 -0
- fastauth/runtime/api.py +432 -0
- fastauth/runtime/auth.py +281 -0
- fastauth/runtime/context.py +45 -0
- fastauth/runtime/event_bus.py +43 -0
- fastauth/runtime/hooks.py +59 -0
- fastauth/security/__init__.py +0 -0
- fastauth/security/jwt.py +371 -0
- fastauth/security/lockout.py +94 -0
- fastauth/security/otp.py +59 -0
- fastauth/security/passwords.py +46 -0
- fastauth/security/rate_limit.py +199 -0
- fastauth/security/refresh_tokens.py +161 -0
- fastauth/security/sessions.py +107 -0
- fastauth/security/tokens.py +66 -0
- fastauth/storage/__init__.py +1 -0
- fastauth/storage/base.py +318 -0
- fastauth/storage/beanie/__init__.py +73 -0
- fastauth/storage/beanie/adapter.py +578 -0
- fastauth/storage/beanie/documents.py +276 -0
- fastauth/storage/beanie/helpers.py +52 -0
- fastauth/storage/memory.py +404 -0
- fastauth/storage/postgres/__init__.py +25 -0
- fastauth/storage/postgres/adapter.py +779 -0
- fastauth/storage/postgres/migrations.py +57 -0
- fastauth/storage/postgres/schema.py +244 -0
- fastauth/web/__init__.py +0 -0
- fastauth/web/csrf.py +122 -0
- fastauth/web/fastapi.py +694 -0
- fastauth/web/security_headers.py +82 -0
- fastauth_py-0.1.0.dist-info/METADATA +326 -0
- fastauth_py-0.1.0.dist-info/RECORD +79 -0
- fastauth_py-0.1.0.dist-info/WHEEL +4 -0
- fastauth_py-0.1.0.dist-info/entry_points.txt +2 -0
- fastauth_py-0.1.0.dist-info/licenses/LICENSE +21 -0
fastauth/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""fastauth — a modular FastAPI authentication library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastauth.config import FastAuthConfig
|
|
6
|
+
from fastauth.runtime.auth import FastAuth
|
|
7
|
+
|
|
8
|
+
__all__ = ["FastAuth", "FastAuthConfig", "__version__"]
|
|
9
|
+
__version__ = "0.1.0"
|
fastauth/cli/__init__.py
ADDED
fastauth/cli/main.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Typer-based CLI for fastauth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import pathlib
|
|
7
|
+
import secrets
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich import print as rich_print
|
|
12
|
+
|
|
13
|
+
__all__ = ["AUTH_SCAFFOLD", "AUTH_SCAFFOLDS", "app", "cli"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(no_args_is_help=True, help="fastauth CLI")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
MEMORY_AUTH_SCAFFOLD = '''\
|
|
20
|
+
"""Authkit instance for this application.
|
|
21
|
+
|
|
22
|
+
This scaffold demonstrates explicit dependency injection. Build your
|
|
23
|
+
``FastAuthConfig`` in your application code, then pass it to ``create_auth``.
|
|
24
|
+
fastauth never reads process-level configuration.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from fastauth import FastAuth, FastAuthConfig
|
|
29
|
+
from fastauth.storage.memory import InMemoryAdapter
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_auth(config: FastAuthConfig) -> FastAuth:
|
|
33
|
+
return FastAuth(config, adapter=InMemoryAdapter())
|
|
34
|
+
'''
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
MONGO_AUTH_SCAFFOLD = '''\
|
|
38
|
+
"""Mongo-backed fastauth instance for this application.
|
|
39
|
+
|
|
40
|
+
Build ``FastAuthConfig`` in your application code. The Mongo URL and database
|
|
41
|
+
name come from ``config.database.mongo``; fastauth never reads process-level
|
|
42
|
+
configuration.
|
|
43
|
+
"""
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
|
49
|
+
|
|
50
|
+
from fastauth import FastAuth, FastAuthConfig
|
|
51
|
+
from fastauth.storage.beanie import BeanieAdapter, init_beanie_documents
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_mongo_database(config: FastAuthConfig) -> AsyncIOMotorDatabase[Any]:
|
|
55
|
+
client: AsyncIOMotorClient[Any] = AsyncIOMotorClient(
|
|
56
|
+
config.database.mongo.url,
|
|
57
|
+
uuidRepresentation="standard",
|
|
58
|
+
)
|
|
59
|
+
return client[config.database.mongo.database_name]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_auth(
|
|
63
|
+
config: FastAuthConfig,
|
|
64
|
+
database: AsyncIOMotorDatabase[Any],
|
|
65
|
+
) -> FastAuth:
|
|
66
|
+
return FastAuth(config, adapter=BeanieAdapter(database))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def init_auth_database(database: AsyncIOMotorDatabase[Any]) -> None:
|
|
70
|
+
await init_beanie_documents(database)
|
|
71
|
+
'''
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
POSTGRES_AUTH_SCAFFOLD = '''\
|
|
75
|
+
"""Postgres-backed fastauth instance for this application.
|
|
76
|
+
|
|
77
|
+
Build ``FastAuthConfig`` in your application code. The Postgres URL and table
|
|
78
|
+
prefix come from ``config.database.postgres``; fastauth never reads
|
|
79
|
+
process-level configuration.
|
|
80
|
+
"""
|
|
81
|
+
from __future__ import annotations
|
|
82
|
+
|
|
83
|
+
from fastapi import FastAPI
|
|
84
|
+
|
|
85
|
+
from fastauth import FastAuth, FastAuthConfig
|
|
86
|
+
from fastauth.storage.postgres import PostgresAdapter
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def create_auth(config: FastAuthConfig) -> FastAuth:
|
|
90
|
+
adapter = PostgresAdapter.from_url(
|
|
91
|
+
config.database.postgres.url,
|
|
92
|
+
table_prefix=config.database.postgres.table_prefix,
|
|
93
|
+
)
|
|
94
|
+
return FastAuth(config, adapter=adapter)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def create_app(config: FastAuthConfig) -> FastAPI:
|
|
98
|
+
auth = create_auth(config)
|
|
99
|
+
adapter = auth.context.adapter
|
|
100
|
+
if not isinstance(adapter, PostgresAdapter):
|
|
101
|
+
raise RuntimeError("expected PostgresAdapter")
|
|
102
|
+
app = FastAPI(lifespan=adapter.checked_lifespan(auth))
|
|
103
|
+
auth.install(app)
|
|
104
|
+
return app
|
|
105
|
+
'''
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
AUTH_SCAFFOLD = MEMORY_AUTH_SCAFFOLD
|
|
109
|
+
AUTH_SCAFFOLDS = {
|
|
110
|
+
"memory": MEMORY_AUTH_SCAFFOLD,
|
|
111
|
+
"mongo": MONGO_AUTH_SCAFFOLD,
|
|
112
|
+
"postgres": POSTGRES_AUTH_SCAFFOLD,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command("init")
|
|
117
|
+
def init_command(
|
|
118
|
+
path: pathlib.Path = typer.Option(pathlib.Path("."), "--path", "-p"), # noqa: B008
|
|
119
|
+
backend: str = typer.Option(
|
|
120
|
+
"memory",
|
|
121
|
+
"--backend",
|
|
122
|
+
"-b",
|
|
123
|
+
help="Scaffold backend: memory, mongo, or postgres",
|
|
124
|
+
),
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Scaffold an ``auth.py`` showing explicit FastAuthConfig construction."""
|
|
127
|
+
backend_key = backend.lower()
|
|
128
|
+
if backend_key not in AUTH_SCAFFOLDS:
|
|
129
|
+
rich_print("[red]--backend must be one of: memory, mongo, postgres[/red]")
|
|
130
|
+
raise typer.Exit(code=1)
|
|
131
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
(path / "auth.py").write_text(AUTH_SCAFFOLDS[backend_key], encoding="utf-8")
|
|
133
|
+
rich_print(f"[green]wrote auth.py to {path}[/green]")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.command("migrate")
|
|
137
|
+
def migrate_command(
|
|
138
|
+
mongo_url: str | None = typer.Option(None, "--mongo-url", "-m", help="MongoDB connection URL"),
|
|
139
|
+
postgres_url: str | None = typer.Option(
|
|
140
|
+
None,
|
|
141
|
+
"--postgres-url",
|
|
142
|
+
help="Postgres connection URL, for example postgresql+asyncpg://...",
|
|
143
|
+
),
|
|
144
|
+
database: str = typer.Option(
|
|
145
|
+
"fastauth",
|
|
146
|
+
"--database",
|
|
147
|
+
"-d",
|
|
148
|
+
help="MongoDB database name",
|
|
149
|
+
),
|
|
150
|
+
postgres_table_prefix: str = typer.Option(
|
|
151
|
+
"fastauth_",
|
|
152
|
+
"--postgres-table-prefix",
|
|
153
|
+
help="Table prefix for Postgres schema creation",
|
|
154
|
+
),
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Initialise database schema/indexes for fastauth storage adapters.
|
|
157
|
+
|
|
158
|
+
Connection details are passed via CLI flags. fastauth does not read
|
|
159
|
+
them from the environment.
|
|
160
|
+
"""
|
|
161
|
+
selected_backends = [mongo_url is not None, postgres_url is not None]
|
|
162
|
+
if sum(selected_backends) != 1:
|
|
163
|
+
rich_print("[red]Pass exactly one of --mongo-url or --postgres-url[/red]")
|
|
164
|
+
raise typer.Exit(code=1)
|
|
165
|
+
|
|
166
|
+
async def run() -> None:
|
|
167
|
+
if mongo_url is not None:
|
|
168
|
+
from motor.motor_asyncio import AsyncIOMotorClient
|
|
169
|
+
|
|
170
|
+
from fastauth.storage.beanie import init_beanie_documents
|
|
171
|
+
|
|
172
|
+
client: AsyncIOMotorClient[Any] = AsyncIOMotorClient(
|
|
173
|
+
mongo_url, uuidRepresentation="standard"
|
|
174
|
+
)
|
|
175
|
+
try:
|
|
176
|
+
await init_beanie_documents(client[database])
|
|
177
|
+
rich_print("[green]indexes ensured on every fastauth collection[/green]")
|
|
178
|
+
finally:
|
|
179
|
+
client.close()
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
from fastauth.storage.postgres import PostgresAdapter
|
|
183
|
+
|
|
184
|
+
assert postgres_url is not None
|
|
185
|
+
adapter = PostgresAdapter.from_url(postgres_url, table_prefix=postgres_table_prefix)
|
|
186
|
+
try:
|
|
187
|
+
applied = await adapter.apply_migrations()
|
|
188
|
+
version = await adapter.schema_version()
|
|
189
|
+
if applied:
|
|
190
|
+
rich_print(f"[green]Postgres migrations applied: {applied}[/green]")
|
|
191
|
+
else:
|
|
192
|
+
rich_print("[green]Postgres schema already current[/green]")
|
|
193
|
+
rich_print(f"[green]Postgres fastauth schema version: {version}[/green]")
|
|
194
|
+
finally:
|
|
195
|
+
await adapter.engine.dispose()
|
|
196
|
+
|
|
197
|
+
asyncio.run(run())
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command("generate-secret")
|
|
201
|
+
def generate_secret_command() -> None:
|
|
202
|
+
"""Print a fresh 64-char URL-safe secret."""
|
|
203
|
+
rich_print(secrets.token_urlsafe(48))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cli() -> None:
|
|
207
|
+
app()
|
fastauth/config.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Pydantic configuration for fastauth.
|
|
2
|
+
|
|
3
|
+
:class:`FastAuthConfig` is a plain Pydantic v2 ``BaseModel``. All values are
|
|
4
|
+
passed explicitly at instantiation time, with Pydantic's validation enforced
|
|
5
|
+
on construction. The framework has no notion of "environment variables" —
|
|
6
|
+
reading from process-level configuration, files, AWS Secrets Manager,
|
|
7
|
+
HashiCorp Vault, or any other source is the consumer's responsibility. Pass
|
|
8
|
+
the values in as constructor arguments and fastauth will validate them.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
|
16
|
+
|
|
17
|
+
from fastauth.domain.enums import (
|
|
18
|
+
DatabaseBackendKind,
|
|
19
|
+
RateLimitStorageKind,
|
|
20
|
+
SessionStrategyKind,
|
|
21
|
+
WireFormat,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AdvancedConfig",
|
|
26
|
+
"AppConfig",
|
|
27
|
+
"CookieConfig",
|
|
28
|
+
"CsrfConfig",
|
|
29
|
+
"DatabaseConfig",
|
|
30
|
+
"DeleteAccountConfig",
|
|
31
|
+
"EmailChangeConfig",
|
|
32
|
+
"EmailConfig",
|
|
33
|
+
"EmailVerificationConfig",
|
|
34
|
+
"FastAuthConfig",
|
|
35
|
+
"LockoutConfig",
|
|
36
|
+
"MemoryDatabaseConfig",
|
|
37
|
+
"MongoDatabaseConfig",
|
|
38
|
+
"PasswordConfig",
|
|
39
|
+
"PasswordResetConfig",
|
|
40
|
+
"PostgresDatabaseConfig",
|
|
41
|
+
"RateLimitConfig",
|
|
42
|
+
"RefreshTokenConfig",
|
|
43
|
+
"SecurityHeadersConfig",
|
|
44
|
+
"SessionConfig",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConfigSection(BaseModel):
|
|
49
|
+
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AppConfig(ConfigSection):
|
|
53
|
+
name: str = "fastauth"
|
|
54
|
+
base_url: str = "http://localhost:8000"
|
|
55
|
+
base_path: str = "/auth"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SessionConfig(ConfigSection):
|
|
59
|
+
strategy: SessionStrategyKind = SessionStrategyKind.DATABASE
|
|
60
|
+
max_age_seconds: int = 60 * 60 * 24 * 7
|
|
61
|
+
idle_timeout_seconds: int | None = None
|
|
62
|
+
rotate_on_refresh: bool = True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CookieConfig(ConfigSection):
|
|
66
|
+
name: str = "fastauth.session_token"
|
|
67
|
+
domain: str | None = None
|
|
68
|
+
path: str = "/"
|
|
69
|
+
secure: bool = True
|
|
70
|
+
http_only: bool = True
|
|
71
|
+
same_site: Literal["lax", "strict", "none"] = "lax"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PasswordConfig(ConfigSection):
|
|
75
|
+
min_length: int = 8
|
|
76
|
+
argon2_time_cost: int = 3
|
|
77
|
+
argon2_memory_cost_kib: int = 64 * 1024 # 64 MiB
|
|
78
|
+
argon2_parallelism: int = 4
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class EmailConfig(ConfigSection):
|
|
82
|
+
from_address: str = "no-reply@localhost"
|
|
83
|
+
from_name: str = "fastauth"
|
|
84
|
+
verification_subject: str = "Verify your email"
|
|
85
|
+
password_reset_subject: str = "Reset your password" # noqa: S105
|
|
86
|
+
template_directory: str | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class EmailVerificationConfig(ConfigSection):
|
|
90
|
+
token_ttl_minutes: int = 15
|
|
91
|
+
require_verified_for_sign_in: bool = False
|
|
92
|
+
base_verify_url: str = "http://localhost:8000/auth/verify-email"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class PasswordResetConfig(ConfigSection):
|
|
96
|
+
token_ttl_minutes: int = 30
|
|
97
|
+
base_reset_url: str = "http://localhost:8000/auth/reset-password"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class EmailChangeConfig(ConfigSection):
|
|
101
|
+
token_ttl_minutes: int = 15
|
|
102
|
+
base_confirm_url: str = "http://localhost:8000/auth/change-email/confirm"
|
|
103
|
+
subject: str = "Confirm your new email address"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class DeleteAccountConfig(ConfigSection):
|
|
107
|
+
token_ttl_minutes: int = 15
|
|
108
|
+
base_confirm_url: str = "http://localhost:8000/auth/delete-account/confirm"
|
|
109
|
+
subject: str = "Confirm account deletion"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class RateLimitConfig(ConfigSection):
|
|
113
|
+
enabled: bool = True
|
|
114
|
+
window_seconds: int = 60
|
|
115
|
+
max_requests: int = 100
|
|
116
|
+
storage: RateLimitStorageKind = RateLimitStorageKind.MEMORY
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class CsrfConfig(ConfigSection):
|
|
120
|
+
enabled: bool = True
|
|
121
|
+
trusted_origins: list[str] = Field(default_factory=list)
|
|
122
|
+
allow_relative_paths: bool = True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class LockoutConfig(ConfigSection):
|
|
126
|
+
"""Account-lockout policy: lock an identifier after N failed sign-ins.
|
|
127
|
+
|
|
128
|
+
``window_seconds`` doubles as the lockout duration — failures older than
|
|
129
|
+
the window are forgotten, and a triggered lockout naturally expires at the
|
|
130
|
+
same horizon. ``max_failures=5`` matches NIST 800-63B's guidance for
|
|
131
|
+
consumer auth (5 is the typical default in libraries like Devise and
|
|
132
|
+
fastapi-users); raise it for low-risk applications or to combat false
|
|
133
|
+
positives from shared NAT.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
enabled: bool = True
|
|
137
|
+
max_failures: int = 5
|
|
138
|
+
window_seconds: int = 15 * 60
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class RefreshTokenConfig(ConfigSection):
|
|
142
|
+
"""Long-lived refresh-token policy.
|
|
143
|
+
|
|
144
|
+
Refresh tokens piggyback on the bearer-token transport: when ``enabled``
|
|
145
|
+
is true (default) AND the sign-up / sign-in request opts into a bearer
|
|
146
|
+
response via ``include_token=true``, the response carries a
|
|
147
|
+
``refresh_token`` that the client can later exchange at
|
|
148
|
+
``POST /auth/refresh`` for a fresh access session. Cookie-only clients
|
|
149
|
+
(``include_token=false``) skip the refresh token entirely — their cookie
|
|
150
|
+
*is* the long-lived credential, so a separate refresh token would be
|
|
151
|
+
redundant.
|
|
152
|
+
|
|
153
|
+
Tokens are rotated on every use (one-time-use; OAuth 2.1 recommendation):
|
|
154
|
+
presenting a refresh token returns a *new* token and marks the old one
|
|
155
|
+
consumed. Presenting a previously-consumed token revokes the entire
|
|
156
|
+
rotation chain (theft-detection) — the user is forced to sign in again.
|
|
157
|
+
|
|
158
|
+
``max_age_seconds`` defaults to 30 days. ``absolute_max_age_seconds``
|
|
159
|
+
caps the total lifetime of a single rotation chain — even with continuous
|
|
160
|
+
rotation, a chain expires after this many seconds since the initial
|
|
161
|
+
sign-in. Set to ``None`` to disable the absolute cap (rotation can extend
|
|
162
|
+
sessions indefinitely as long as the user is active).
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
enabled: bool = True
|
|
166
|
+
max_age_seconds: int = 30 * 24 * 60 * 60
|
|
167
|
+
absolute_max_age_seconds: int | None = None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class SecurityHeadersConfig(ConfigSection):
|
|
171
|
+
"""Response-header hardening (HSTS, frame-ancestors, MIME-sniffing, …).
|
|
172
|
+
|
|
173
|
+
Defaults match the OWASP Secure Headers Project's recommendations for a
|
|
174
|
+
typical SaaS web application. Every header is individually toggleable —
|
|
175
|
+
set ``hsts=None`` (etc.) to omit. The default ``hsts`` value
|
|
176
|
+
(``"max-age=31536000; includeSubDomains"``) is conservative; production
|
|
177
|
+
deployments preloaded into the HSTS preload list should add ``; preload``.
|
|
178
|
+
|
|
179
|
+
``content_security_policy`` defaults to ``None`` because a meaningful CSP
|
|
180
|
+
is application-specific. Set it to a string and the middleware will emit
|
|
181
|
+
a ``Content-Security-Policy`` header verbatim.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
enabled: bool = True
|
|
185
|
+
hsts: str | None = "max-age=31536000; includeSubDomains"
|
|
186
|
+
x_frame_options: str | None = "DENY"
|
|
187
|
+
x_content_type_options: str | None = "nosniff"
|
|
188
|
+
referrer_policy: str | None = "strict-origin-when-cross-origin"
|
|
189
|
+
permissions_policy: str | None = None
|
|
190
|
+
content_security_policy: str | None = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class MongoDatabaseConfig(ConfigSection):
|
|
194
|
+
url: str = "mongodb://localhost:27017"
|
|
195
|
+
database_name: str = "fastauth"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class PostgresDatabaseConfig(ConfigSection):
|
|
199
|
+
url: str = "postgresql+asyncpg://localhost/fastauth"
|
|
200
|
+
table_prefix: str = "fastauth_"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class MemoryDatabaseConfig(ConfigSection):
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class DatabaseConfig(ConfigSection):
|
|
208
|
+
backend: DatabaseBackendKind = DatabaseBackendKind.MEMORY
|
|
209
|
+
memory: MemoryDatabaseConfig = Field(default_factory=MemoryDatabaseConfig)
|
|
210
|
+
mongo: MongoDatabaseConfig = Field(default_factory=MongoDatabaseConfig)
|
|
211
|
+
postgres: PostgresDatabaseConfig = Field(default_factory=PostgresDatabaseConfig)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class AdvancedConfig(ConfigSection):
|
|
215
|
+
ip_address_headers: list[str] = Field(default_factory=lambda: ["x-forwarded-for"])
|
|
216
|
+
ipv6_subnet: int = 64
|
|
217
|
+
cookie_secure_prefix: bool = True
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def empty_secret_str_list() -> list[SecretStr]:
|
|
221
|
+
"""Typed default factory for ``secret_key_rotation`` (keeps pyright strict happy)."""
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class FastAuthConfig(BaseModel):
|
|
226
|
+
"""Top-level fastauth configuration.
|
|
227
|
+
|
|
228
|
+
A plain Pydantic v2 ``BaseModel``. Construction validates the entire tree
|
|
229
|
+
eagerly. **The framework never reads process-level configuration or any
|
|
230
|
+
other external source** — every value comes from the constructor. Consumers
|
|
231
|
+
should read their chosen configuration source in their own code and pass
|
|
232
|
+
the values in explicitly.
|
|
233
|
+
|
|
234
|
+
Example::
|
|
235
|
+
|
|
236
|
+
from pydantic import SecretStr
|
|
237
|
+
from fastauth import FastAuth, FastAuthConfig
|
|
238
|
+
from fastauth.config import DatabaseConfig, MongoDatabaseConfig
|
|
239
|
+
|
|
240
|
+
config = FastAuthConfig(
|
|
241
|
+
secret_key=SecretStr("..."),
|
|
242
|
+
database=DatabaseConfig(
|
|
243
|
+
backend="mongo",
|
|
244
|
+
mongo=MongoDatabaseConfig(url="mongodb://localhost:27017"),
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
auth = FastAuth(config, adapter=...)
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
251
|
+
|
|
252
|
+
secret_key: SecretStr
|
|
253
|
+
secret_key_rotation: list[SecretStr] = Field(default_factory=empty_secret_str_list)
|
|
254
|
+
app: AppConfig = Field(default_factory=AppConfig)
|
|
255
|
+
session: SessionConfig = Field(default_factory=SessionConfig)
|
|
256
|
+
cookie: CookieConfig = Field(default_factory=CookieConfig)
|
|
257
|
+
password: PasswordConfig = Field(default_factory=PasswordConfig)
|
|
258
|
+
email: EmailConfig = Field(default_factory=EmailConfig)
|
|
259
|
+
email_verification: EmailVerificationConfig = Field(default_factory=EmailVerificationConfig)
|
|
260
|
+
password_reset: PasswordResetConfig = Field(default_factory=PasswordResetConfig)
|
|
261
|
+
email_change: EmailChangeConfig = Field(default_factory=EmailChangeConfig)
|
|
262
|
+
delete_account: DeleteAccountConfig = Field(default_factory=DeleteAccountConfig)
|
|
263
|
+
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
|
|
264
|
+
csrf: CsrfConfig = Field(default_factory=CsrfConfig)
|
|
265
|
+
lockout: LockoutConfig = Field(default_factory=LockoutConfig)
|
|
266
|
+
refresh_token: RefreshTokenConfig = Field(default_factory=RefreshTokenConfig)
|
|
267
|
+
security_headers: SecurityHeadersConfig = Field(default_factory=SecurityHeadersConfig)
|
|
268
|
+
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
|
|
269
|
+
advanced: AdvancedConfig = Field(default_factory=AdvancedConfig)
|
|
270
|
+
wire_format: WireFormat = WireFormat.SNAKE
|
|
File without changes
|
fastauth/domain/enums.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Project-wide string enumerations. Every closed set of strings lives here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AuditEventType",
|
|
9
|
+
"DatabaseBackendKind",
|
|
10
|
+
"EmailMessageKind",
|
|
11
|
+
"HookPhase",
|
|
12
|
+
"JwtAlgorithm",
|
|
13
|
+
"ProviderId",
|
|
14
|
+
"RateLimitStorageKind",
|
|
15
|
+
"SessionStrategyKind",
|
|
16
|
+
"TokenType",
|
|
17
|
+
"VerificationPurpose",
|
|
18
|
+
"WireFormat",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProviderId(StrEnum):
|
|
23
|
+
CREDENTIAL = "credential"
|
|
24
|
+
EMAIL_OTP = "email-otp"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class VerificationPurpose(StrEnum):
|
|
28
|
+
EMAIL_VERIFICATION = "email-verification"
|
|
29
|
+
PASSWORD_RESET = "password-reset" # noqa: S105
|
|
30
|
+
EMAIL_CHANGE = "email-change"
|
|
31
|
+
ACCOUNT_DELETION = "account-deletion"
|
|
32
|
+
EMAIL_OTP_SIGN_IN = "email-otp-sign-in"
|
|
33
|
+
EMAIL_OTP_VERIFICATION = "email-otp-verification"
|
|
34
|
+
EMAIL_OTP_PASSWORD_RESET = "email-otp-password-reset" # noqa: S105
|
|
35
|
+
EMAIL_OTP_EMAIL_CHANGE = "email-otp-email-change"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuditEventType(StrEnum):
|
|
39
|
+
USER_SIGNED_UP = "user_signed_up"
|
|
40
|
+
USER_SIGNED_IN = "user_signed_in"
|
|
41
|
+
USER_SIGNED_OUT = "user_signed_out"
|
|
42
|
+
USER_EMAIL_VERIFIED = "user_email_verified"
|
|
43
|
+
USER_UPDATED = "user_updated"
|
|
44
|
+
USER_EMAIL_CHANGE_REQUESTED = "user_email_change_requested"
|
|
45
|
+
USER_EMAIL_CHANGED = "user_email_changed"
|
|
46
|
+
USER_DELETE_REQUESTED = "user_delete_requested"
|
|
47
|
+
USER_DELETED = "user_deleted"
|
|
48
|
+
SESSION_CREATED = "session_created"
|
|
49
|
+
SESSION_REVOKED = "session_revoked"
|
|
50
|
+
SESSIONS_REVOKED_ALL = "sessions_revoked_all"
|
|
51
|
+
ACCOUNT_LINKED = "account_linked"
|
|
52
|
+
ACCOUNT_UNLINKED = "account_unlinked"
|
|
53
|
+
PASSWORD_CHANGED = "password_changed" # noqa: S105
|
|
54
|
+
PASSWORD_RESET_REQUESTED = "password_reset_requested" # noqa: S105
|
|
55
|
+
PASSWORD_RESET_COMPLETED = "password_reset_completed" # noqa: S105
|
|
56
|
+
EMAIL_VERIFICATION_SENT = "email_verification_sent"
|
|
57
|
+
API_KEY_CREATED = "api_key_created"
|
|
58
|
+
API_KEY_REVOKED = "api_key_revoked"
|
|
59
|
+
API_KEY_VERIFIED_FAILED = "api_key_verified_failed"
|
|
60
|
+
SECURITY_VELOCITY_EXCEEDED = "security_velocity_exceeded"
|
|
61
|
+
ACCOUNT_LOCKED = "account_locked"
|
|
62
|
+
OTP_REQUESTED = "otp_requested"
|
|
63
|
+
OTP_VERIFIED = "otp_verified"
|
|
64
|
+
OTP_VERIFY_FAILED = "otp_verify_failed"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SessionStrategyKind(StrEnum):
|
|
68
|
+
DATABASE = "database"
|
|
69
|
+
JWT = "jwt"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DatabaseBackendKind(StrEnum):
|
|
73
|
+
MEMORY = "memory"
|
|
74
|
+
MONGO = "mongo"
|
|
75
|
+
POSTGRES = "postgres"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class WireFormat(StrEnum):
|
|
79
|
+
"""JSON casing convention applied to public request / response bodies.
|
|
80
|
+
|
|
81
|
+
``SNAKE`` (default) emits Pythonic ``snake_case`` field names — the
|
|
82
|
+
historical and back-compat behaviour. ``CAMEL`` emits ``camelCase``
|
|
83
|
+
(e.g. ``email_verified`` → ``emailVerified``, ``refresh_token`` →
|
|
84
|
+
``refreshToken``).
|
|
85
|
+
|
|
86
|
+
Both casings are always **accepted** on input regardless of this
|
|
87
|
+
setting — toggling only affects output.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
SNAKE = "snake"
|
|
91
|
+
CAMEL = "camel"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TokenType(StrEnum):
|
|
95
|
+
SESSION = "session"
|
|
96
|
+
VERIFICATION = "verification"
|
|
97
|
+
API_KEY = "api-key"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class HookPhase(StrEnum):
|
|
101
|
+
BEFORE_CREATE = "before_create"
|
|
102
|
+
AFTER_CREATE = "after_create"
|
|
103
|
+
BEFORE_UPDATE = "before_update"
|
|
104
|
+
AFTER_UPDATE = "after_update"
|
|
105
|
+
BEFORE_DELETE = "before_delete"
|
|
106
|
+
AFTER_DELETE = "after_delete"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class RateLimitStorageKind(StrEnum):
|
|
110
|
+
MEMORY = "memory"
|
|
111
|
+
DATABASE = "database"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class EmailMessageKind(StrEnum):
|
|
115
|
+
VERIFICATION = "verification"
|
|
116
|
+
PASSWORD_RESET = "password-reset" # noqa: S105
|
|
117
|
+
ACCOUNT_DELETION = "account-deletion"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class JwtAlgorithm(StrEnum):
|
|
121
|
+
EDDSA = "EdDSA"
|
|
122
|
+
ES256 = "ES256"
|
|
123
|
+
RS256 = "RS256"
|
|
124
|
+
PS256 = "PS256"
|
|
125
|
+
ES512 = "ES512"
|