softauth 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.
- softauth/__init__.py +73 -0
- softauth/cli/__init__.py +3 -0
- softauth/cli/commands.py +233 -0
- softauth/core/__init__.py +35 -0
- softauth/core/auth.py +183 -0
- softauth/core/config.py +70 -0
- softauth/core/exceptions.py +67 -0
- softauth/database/__init__.py +5 -0
- softauth/database/models.py +58 -0
- softauth/database/repository.py +46 -0
- softauth/database/session.py +66 -0
- softauth/django/__init__.py +3 -0
- softauth/django/adapter.py +77 -0
- softauth/django/decorators.py +68 -0
- softauth/django/middleware.py +59 -0
- softauth/django/urls.py +36 -0
- softauth/django/views.py +139 -0
- softauth/fastapi/__init__.py +6 -0
- softauth/fastapi/adapter.py +55 -0
- softauth/fastapi/dependencies.py +107 -0
- softauth/fastapi/middleware.py +54 -0
- softauth/fastapi/routes.py +147 -0
- softauth/flask/__init__.py +6 -0
- softauth/flask/adapter.py +63 -0
- softauth/flask/decorators.py +73 -0
- softauth/flask/middleware.py +39 -0
- softauth/flask/routes.py +118 -0
- softauth/interfaces/__init__.py +12 -0
- softauth/interfaces/adapter.py +48 -0
- softauth/interfaces/auth_provider.py +34 -0
- softauth/interfaces/token_store.py +37 -0
- softauth/interfaces/user_store.py +39 -0
- softauth/jwt/__init__.py +4 -0
- softauth/jwt/handler.py +99 -0
- softauth/jwt/schemas.py +37 -0
- softauth/security/__init__.py +3 -0
- softauth/security/password.py +32 -0
- softauth-0.1.0.dist-info/METADATA +307 -0
- softauth-0.1.0.dist-info/RECORD +43 -0
- softauth-0.1.0.dist-info/WHEEL +5 -0
- softauth-0.1.0.dist-info/entry_points.txt +2 -0
- softauth-0.1.0.dist-info/licenses/LICENSE +21 -0
- softauth-0.1.0.dist-info/top_level.txt +1 -0
softauth/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""softauth — Zero-setup JWT authentication for FastAPI and Flask.
|
|
2
|
+
|
|
3
|
+
Quick start (FastAPI)::
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, Depends
|
|
6
|
+
from softauth import SoftAuth
|
|
7
|
+
|
|
8
|
+
auth = SoftAuth(secret_key="your-secret", framework="fastapi")
|
|
9
|
+
app = FastAPI()
|
|
10
|
+
auth.init_app(app)
|
|
11
|
+
auth.init_db()
|
|
12
|
+
|
|
13
|
+
@app.get("/me")
|
|
14
|
+
def me(user=Depends(auth.current_user)):
|
|
15
|
+
return user.to_dict()
|
|
16
|
+
|
|
17
|
+
Quick start (Flask)::
|
|
18
|
+
|
|
19
|
+
from flask import Flask, g
|
|
20
|
+
from softauth import SoftAuth
|
|
21
|
+
|
|
22
|
+
auth = SoftAuth(secret_key="your-secret", framework="flask")
|
|
23
|
+
app = Flask(__name__)
|
|
24
|
+
auth.init_app(app)
|
|
25
|
+
auth.init_db()
|
|
26
|
+
|
|
27
|
+
@app.route("/me")
|
|
28
|
+
@auth.login_required
|
|
29
|
+
def me():
|
|
30
|
+
return g.user.to_dict()
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from softauth.core.auth import SoftAuth
|
|
34
|
+
from softauth.core.config import SoftAuthConfig
|
|
35
|
+
from softauth.core.exceptions import (
|
|
36
|
+
AdapterNotInitializedError,
|
|
37
|
+
AuthError,
|
|
38
|
+
ConfigurationError,
|
|
39
|
+
InactiveUserError,
|
|
40
|
+
InvalidCredentialsError,
|
|
41
|
+
InvalidTokenError,
|
|
42
|
+
PermissionDeniedError,
|
|
43
|
+
SoftAuthError,
|
|
44
|
+
TokenError,
|
|
45
|
+
TokenExpiredError,
|
|
46
|
+
TokenTypeMismatchError,
|
|
47
|
+
UserAlreadyExistsError,
|
|
48
|
+
UserNotFoundError,
|
|
49
|
+
)
|
|
50
|
+
from softauth.jwt.handler import JWTHandler
|
|
51
|
+
from softauth.security.password import PasswordHandler
|
|
52
|
+
|
|
53
|
+
__version__ = "0.1.0"
|
|
54
|
+
__all__ = [
|
|
55
|
+
"SoftAuth",
|
|
56
|
+
"SoftAuthConfig",
|
|
57
|
+
"JWTHandler",
|
|
58
|
+
"PasswordHandler",
|
|
59
|
+
# Exceptions
|
|
60
|
+
"SoftAuthError",
|
|
61
|
+
"AuthError",
|
|
62
|
+
"TokenError",
|
|
63
|
+
"TokenExpiredError",
|
|
64
|
+
"InvalidTokenError",
|
|
65
|
+
"TokenTypeMismatchError",
|
|
66
|
+
"InvalidCredentialsError",
|
|
67
|
+
"InactiveUserError",
|
|
68
|
+
"UserNotFoundError",
|
|
69
|
+
"UserAlreadyExistsError",
|
|
70
|
+
"PermissionDeniedError",
|
|
71
|
+
"ConfigurationError",
|
|
72
|
+
"AdapterNotInitializedError",
|
|
73
|
+
]
|
softauth/cli/__init__.py
ADDED
softauth/cli/commands.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Typer CLI for softauth.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
softauth init — scaffold .env and auth/ directory
|
|
5
|
+
softauth setup fastapi — generate a ready-to-run FastAPI main.py
|
|
6
|
+
softauth setup flask — generate a ready-to-run Flask app.py
|
|
7
|
+
softauth secret — print a cryptographically secure key
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import secrets
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="softauth",
|
|
20
|
+
help="softauth — zero-setup JWT authentication CLI",
|
|
21
|
+
add_completion=False,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
setup_app = typer.Typer(help="Generate framework-specific boilerplate.")
|
|
25
|
+
app.add_typer(setup_app, name="setup")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── softauth init ──────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def init() -> None:
|
|
32
|
+
"""Scaffold the auth/ directory and a .env file with a fresh secret key."""
|
|
33
|
+
auth_dir = Path("auth")
|
|
34
|
+
auth_dir.mkdir(exist_ok=True)
|
|
35
|
+
(auth_dir / "__init__.py").touch()
|
|
36
|
+
|
|
37
|
+
env_path = Path(".env")
|
|
38
|
+
if env_path.exists():
|
|
39
|
+
typer.echo(".env already exists — skipping.")
|
|
40
|
+
else:
|
|
41
|
+
secret = secrets.token_hex(32)
|
|
42
|
+
env_path.write_text(
|
|
43
|
+
f"# SoftAuth — do NOT commit this file\n"
|
|
44
|
+
f"SOFTAUTH_SECRET={secret}\n"
|
|
45
|
+
f"SOFTAUTH_DB_URL=sqlite:///auth.db\n"
|
|
46
|
+
f"SOFTAUTH_ALGORITHM=HS256\n",
|
|
47
|
+
encoding="utf-8",
|
|
48
|
+
)
|
|
49
|
+
typer.echo(f"Created .env with a fresh secret key.")
|
|
50
|
+
|
|
51
|
+
typer.echo(f"Initialised auth/ directory.")
|
|
52
|
+
typer.echo("Next steps:")
|
|
53
|
+
typer.echo(" softauth setup fastapi (or flask)")
|
|
54
|
+
typer.echo(" python -m uvicorn main:app --reload")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── softauth setup fastapi ─────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
@setup_app.command("fastapi")
|
|
60
|
+
def setup_fastapi() -> None:
|
|
61
|
+
"""Generate a ready-to-run FastAPI application with softauth wired in."""
|
|
62
|
+
target = Path("main.py")
|
|
63
|
+
if target.exists():
|
|
64
|
+
typer.echo("main.py already exists — skipping.")
|
|
65
|
+
raise typer.Exit()
|
|
66
|
+
|
|
67
|
+
target.write_text(
|
|
68
|
+
'''\
|
|
69
|
+
from fastapi import FastAPI, Depends
|
|
70
|
+
from softauth import SoftAuth
|
|
71
|
+
from dotenv import load_dotenv
|
|
72
|
+
import os
|
|
73
|
+
|
|
74
|
+
load_dotenv()
|
|
75
|
+
|
|
76
|
+
auth = SoftAuth(
|
|
77
|
+
secret_key=os.environ["SOFTAUTH_SECRET"],
|
|
78
|
+
framework="fastapi",
|
|
79
|
+
database_url=os.getenv("SOFTAUTH_DB_URL", "sqlite:///auth.db"),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
app = FastAPI(title="My App")
|
|
83
|
+
auth.init_app(app)
|
|
84
|
+
auth.init_db()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── Protected routes ──────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
@app.get("/profile")
|
|
90
|
+
def profile(user=Depends(auth.current_user)):
|
|
91
|
+
return user.to_dict()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.get("/admin")
|
|
95
|
+
def admin_dashboard(user=Depends(auth.current_admin)):
|
|
96
|
+
return {"message": f"Welcome, admin {user.email}"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.get("/manager")
|
|
100
|
+
def manager_page(user=Depends(auth.require_role("manager"))):
|
|
101
|
+
return {"message": f"Welcome, manager {user.email}"}
|
|
102
|
+
''',
|
|
103
|
+
encoding="utf-8",
|
|
104
|
+
)
|
|
105
|
+
typer.echo("Created main.py — run with: uvicorn main:app --reload")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── softauth setup flask ───────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
@setup_app.command("flask")
|
|
111
|
+
def setup_flask() -> None:
|
|
112
|
+
"""Generate a ready-to-run Flask application with softauth wired in."""
|
|
113
|
+
target = Path("app.py")
|
|
114
|
+
if target.exists():
|
|
115
|
+
typer.echo("app.py already exists — skipping.")
|
|
116
|
+
raise typer.Exit()
|
|
117
|
+
|
|
118
|
+
target.write_text(
|
|
119
|
+
'''\
|
|
120
|
+
from flask import Flask, g, jsonify
|
|
121
|
+
from softauth import SoftAuth
|
|
122
|
+
from dotenv import load_dotenv
|
|
123
|
+
import os
|
|
124
|
+
|
|
125
|
+
load_dotenv()
|
|
126
|
+
|
|
127
|
+
auth = SoftAuth(
|
|
128
|
+
secret_key=os.environ["SOFTAUTH_SECRET"],
|
|
129
|
+
framework="flask",
|
|
130
|
+
database_url=os.getenv("SOFTAUTH_DB_URL", "sqlite:///auth.db"),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
app = Flask(__name__)
|
|
134
|
+
auth.init_app(app)
|
|
135
|
+
auth.init_db()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── Protected routes ──────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
@app.route("/profile")
|
|
141
|
+
@auth.login_required
|
|
142
|
+
def profile():
|
|
143
|
+
return jsonify(g.user.to_dict())
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.route("/admin")
|
|
147
|
+
@auth.admin_required
|
|
148
|
+
def admin_dashboard():
|
|
149
|
+
return jsonify({"message": f"Welcome, admin {g.user.email}"})
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.route("/manager")
|
|
153
|
+
@auth.require_role("manager")
|
|
154
|
+
def manager_page():
|
|
155
|
+
return jsonify({"message": f"Welcome, manager {g.user.email}"})
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
app.run(debug=True)
|
|
160
|
+
''',
|
|
161
|
+
encoding="utf-8",
|
|
162
|
+
)
|
|
163
|
+
typer.echo("Created app.py — run with: flask run")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── softauth setup django ─────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
@setup_app.command("django")
|
|
169
|
+
def setup_django() -> None:
|
|
170
|
+
"""Generate a ready-to-run Django application with softauth wired in."""
|
|
171
|
+
target = Path("views.py")
|
|
172
|
+
if target.exists():
|
|
173
|
+
typer.echo("views.py already exists — skipping.")
|
|
174
|
+
raise typer.Exit()
|
|
175
|
+
|
|
176
|
+
target.write_text(
|
|
177
|
+
'''\
|
|
178
|
+
from django.http import JsonResponse
|
|
179
|
+
from softauth import SoftAuth
|
|
180
|
+
from dotenv import load_dotenv
|
|
181
|
+
import os
|
|
182
|
+
|
|
183
|
+
load_dotenv()
|
|
184
|
+
|
|
185
|
+
auth = SoftAuth(
|
|
186
|
+
secret_key=os.environ["SOFTAUTH_SECRET"],
|
|
187
|
+
framework="django",
|
|
188
|
+
database_url=os.getenv("SOFTAUTH_DB_URL", "sqlite:///auth.db"),
|
|
189
|
+
)
|
|
190
|
+
auth.init_db()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ── In urls.py ────────────────────────────────────────────────────────────────
|
|
194
|
+
#
|
|
195
|
+
# from django.urls import path
|
|
196
|
+
# from .views import auth
|
|
197
|
+
#
|
|
198
|
+
# urlpatterns = []
|
|
199
|
+
# auth.init_app(urlpatterns) # appends /auth/* routes
|
|
200
|
+
#
|
|
201
|
+
# Also add to settings.py MIDDLEWARE:
|
|
202
|
+
# "softauth.django.middleware.SoftAuthMiddleware",
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ── Protected views ───────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
@auth.login_required
|
|
208
|
+
def profile(request):
|
|
209
|
+
return JsonResponse(request.softauth_user.to_dict())
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@auth.admin_required
|
|
213
|
+
def admin_dashboard(request):
|
|
214
|
+
return JsonResponse({"message": f"Welcome, admin {request.softauth_user.email}"})
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@auth.require_role("manager")
|
|
218
|
+
def manager_page(request):
|
|
219
|
+
return JsonResponse({"message": f"Welcome, manager {request.softauth_user.email}"})
|
|
220
|
+
''',
|
|
221
|
+
encoding="utf-8",
|
|
222
|
+
)
|
|
223
|
+
typer.echo("Created views.py — wire it into urls.py and add SoftAuthMiddleware to MIDDLEWARE.")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── softauth secret ────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
@app.command()
|
|
229
|
+
def secret(
|
|
230
|
+
length: int = typer.Option(32, "--length", "-l", help="Number of random bytes (hex output is 2× longer)"),
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Print a cryptographically secure random secret key."""
|
|
233
|
+
typer.echo(secrets.token_hex(length))
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from softauth.core.auth import SoftAuth
|
|
2
|
+
from softauth.core.config import SoftAuthConfig
|
|
3
|
+
from softauth.core.exceptions import (
|
|
4
|
+
AdapterNotInitializedError,
|
|
5
|
+
AuthError,
|
|
6
|
+
ConfigurationError,
|
|
7
|
+
InactiveUserError,
|
|
8
|
+
InvalidCredentialsError,
|
|
9
|
+
InvalidTokenError,
|
|
10
|
+
PermissionDeniedError,
|
|
11
|
+
SoftAuthError,
|
|
12
|
+
TokenError,
|
|
13
|
+
TokenExpiredError,
|
|
14
|
+
TokenTypeMismatchError,
|
|
15
|
+
UserAlreadyExistsError,
|
|
16
|
+
UserNotFoundError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"SoftAuth",
|
|
21
|
+
"SoftAuthConfig",
|
|
22
|
+
"SoftAuthError",
|
|
23
|
+
"AuthError",
|
|
24
|
+
"TokenError",
|
|
25
|
+
"TokenExpiredError",
|
|
26
|
+
"InvalidTokenError",
|
|
27
|
+
"TokenTypeMismatchError",
|
|
28
|
+
"InvalidCredentialsError",
|
|
29
|
+
"InactiveUserError",
|
|
30
|
+
"UserNotFoundError",
|
|
31
|
+
"UserAlreadyExistsError",
|
|
32
|
+
"PermissionDeniedError",
|
|
33
|
+
"ConfigurationError",
|
|
34
|
+
"AdapterNotInitializedError",
|
|
35
|
+
]
|
softauth/core/auth.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""SoftAuth — the public facade for the entire library.
|
|
2
|
+
|
|
3
|
+
Usage (FastAPI):
|
|
4
|
+
auth = SoftAuth(secret_key="...", framework="fastapi")
|
|
5
|
+
auth.init_app(app)
|
|
6
|
+
auth.init_db()
|
|
7
|
+
|
|
8
|
+
Usage (Flask):
|
|
9
|
+
auth = SoftAuth(secret_key="...", framework="flask")
|
|
10
|
+
auth.init_app(app)
|
|
11
|
+
auth.init_db()
|
|
12
|
+
|
|
13
|
+
The core is framework-agnostic: JWT and password handling live in
|
|
14
|
+
``softauth.jwt`` and ``softauth.security``. Framework adapters (FastAPI,
|
|
15
|
+
Flask, …) implement ``BaseAdapter`` and are loaded lazily so that having
|
|
16
|
+
only one framework installed does not cause import errors.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any, Callable, Optional
|
|
22
|
+
|
|
23
|
+
from softauth.core.config import SoftAuthConfig
|
|
24
|
+
from softauth.core.exceptions import AdapterNotInitializedError, ConfigurationError
|
|
25
|
+
from softauth.database.session import DatabaseSession
|
|
26
|
+
from softauth.interfaces.adapter import BaseAdapter
|
|
27
|
+
from softauth.jwt.handler import JWTHandler
|
|
28
|
+
from softauth.security.password import PasswordHandler
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SoftAuth:
|
|
32
|
+
"""Zero-setup JWT authentication for FastAPI and Flask.
|
|
33
|
+
|
|
34
|
+
All arguments to ``__init__`` mirror ``SoftAuthConfig`` fields.
|
|
35
|
+
Pass any SoftAuthConfig field as a keyword argument.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
secret_key: str,
|
|
41
|
+
*,
|
|
42
|
+
framework: Optional[str] = None,
|
|
43
|
+
database_url: str = "sqlite:///auth.db",
|
|
44
|
+
algorithm: str = "HS256",
|
|
45
|
+
access_expiry_minutes: int = 15,
|
|
46
|
+
refresh_expiry_days: int = 7,
|
|
47
|
+
auth_prefix: str = "/auth",
|
|
48
|
+
enable_refresh_tokens: bool = True,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.config = SoftAuthConfig(
|
|
51
|
+
secret_key=secret_key,
|
|
52
|
+
framework=framework, # type: ignore[arg-type]
|
|
53
|
+
database_url=database_url,
|
|
54
|
+
algorithm=algorithm,
|
|
55
|
+
access_expiry_minutes=access_expiry_minutes,
|
|
56
|
+
refresh_expiry_days=refresh_expiry_days,
|
|
57
|
+
auth_prefix=auth_prefix,
|
|
58
|
+
enable_refresh_tokens=enable_refresh_tokens,
|
|
59
|
+
)
|
|
60
|
+
self.jwt = JWTHandler(self.config)
|
|
61
|
+
self.passwords = PasswordHandler()
|
|
62
|
+
self._db = DatabaseSession(self.config.database_url)
|
|
63
|
+
self._adapter: Optional[BaseAdapter] = None
|
|
64
|
+
|
|
65
|
+
# ── Initialisation ────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def init_app(self, app: Any) -> None:
|
|
68
|
+
"""Attach SoftAuth to *app*.
|
|
69
|
+
|
|
70
|
+
Mounts routes and middleware for the configured framework.
|
|
71
|
+
Must be called before the first request is served.
|
|
72
|
+
"""
|
|
73
|
+
fw = self.config.framework
|
|
74
|
+
if fw == "fastapi":
|
|
75
|
+
from softauth.fastapi.adapter import FastAPIAdapter
|
|
76
|
+
self._adapter = FastAPIAdapter(self, self.config)
|
|
77
|
+
elif fw == "flask":
|
|
78
|
+
from softauth.flask.adapter import FlaskAdapter
|
|
79
|
+
self._adapter = FlaskAdapter(self, self.config)
|
|
80
|
+
elif fw == "django":
|
|
81
|
+
from softauth.django.adapter import DjangoAdapter
|
|
82
|
+
self._adapter = DjangoAdapter(self, self.config)
|
|
83
|
+
elif fw is None:
|
|
84
|
+
raise ConfigurationError(
|
|
85
|
+
"No framework specified. Pass framework='fastapi' or framework='flask' "
|
|
86
|
+
"to SoftAuth(), or use a custom adapter."
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
raise ConfigurationError(
|
|
90
|
+
f"Unknown framework '{fw}'. "
|
|
91
|
+
"Built-in choices: 'fastapi', 'flask', 'django'. "
|
|
92
|
+
"For other frameworks implement BaseAdapter and call use_adapter()."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self._adapter.on_startup()
|
|
96
|
+
self._adapter.init_app(app)
|
|
97
|
+
|
|
98
|
+
def use_adapter(self, adapter: BaseAdapter, app: Any) -> None:
|
|
99
|
+
"""Register a custom BaseAdapter instead of a built-in one.
|
|
100
|
+
|
|
101
|
+
This is the extension point for Django, Litestar, Quart, or any
|
|
102
|
+
other framework without touching SoftAuth internals.
|
|
103
|
+
|
|
104
|
+
Example::
|
|
105
|
+
|
|
106
|
+
class DjangoAdapter(BaseAdapter): ...
|
|
107
|
+
|
|
108
|
+
auth = SoftAuth(secret_key="...", framework=None)
|
|
109
|
+
auth.use_adapter(DjangoAdapter(auth, auth.config), django_app)
|
|
110
|
+
"""
|
|
111
|
+
self._adapter = adapter
|
|
112
|
+
adapter.on_startup()
|
|
113
|
+
adapter.init_app(app)
|
|
114
|
+
|
|
115
|
+
def init_db(self) -> None:
|
|
116
|
+
"""Create the softauth_users table (and any future tables).
|
|
117
|
+
|
|
118
|
+
Idempotent — safe to call on every startup.
|
|
119
|
+
"""
|
|
120
|
+
self._db.create_tables()
|
|
121
|
+
|
|
122
|
+
# ── Auth dependency / decorator accessors ─────────────────────────────
|
|
123
|
+
|
|
124
|
+
def _require_adapter(self) -> BaseAdapter:
|
|
125
|
+
if self._adapter is None:
|
|
126
|
+
raise AdapterNotInitializedError(
|
|
127
|
+
"Call auth.init_app(app) before accessing auth dependencies."
|
|
128
|
+
)
|
|
129
|
+
return self._adapter
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def current_user(self) -> Any:
|
|
133
|
+
"""FastAPI: ``Depends(auth.current_user)`` | Flask: ``@auth.login_required``."""
|
|
134
|
+
return self._require_adapter().get_current_user_dependency()
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def current_admin(self) -> Any:
|
|
138
|
+
"""FastAPI: ``Depends(auth.current_admin)`` | Flask: ``@auth.admin_required``."""
|
|
139
|
+
return self._require_adapter().get_current_admin_dependency()
|
|
140
|
+
|
|
141
|
+
def require_role(self, role: str) -> Any:
|
|
142
|
+
"""FastAPI: ``Depends(auth.require_role('editor'))``
|
|
143
|
+
Flask: ``@auth.require_role('editor')``
|
|
144
|
+
"""
|
|
145
|
+
return self._require_adapter().get_require_role_dependency(role)
|
|
146
|
+
|
|
147
|
+
# ── Flask-flavoured shorthand properties ──────────────────────────────
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def login_required(self) -> Any:
|
|
151
|
+
"""Flask convenience alias for ``current_user`` (used as decorator)."""
|
|
152
|
+
adapter = self._require_adapter()
|
|
153
|
+
if not hasattr(adapter, "login_required"):
|
|
154
|
+
return adapter.get_current_user_dependency()
|
|
155
|
+
return adapter.login_required # type: ignore[union-attr]
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def admin_required(self) -> Any:
|
|
159
|
+
"""Flask convenience alias for ``current_admin`` (used as decorator)."""
|
|
160
|
+
adapter = self._require_adapter()
|
|
161
|
+
if not hasattr(adapter, "admin_required"):
|
|
162
|
+
return adapter.get_current_admin_dependency()
|
|
163
|
+
return adapter.admin_required # type: ignore[union-attr]
|
|
164
|
+
|
|
165
|
+
# ── Convenience passthrough ────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
def hash_password(self, plain: str) -> str:
|
|
168
|
+
return self.passwords.hash_password(plain)
|
|
169
|
+
|
|
170
|
+
def verify_password(self, plain: str, hashed: str) -> bool:
|
|
171
|
+
return self.passwords.verify_password(plain, hashed)
|
|
172
|
+
|
|
173
|
+
def create_access_token(self, subject: str, role: Optional[str] = None, **extra: Any) -> str:
|
|
174
|
+
return self.jwt.create_access_token(subject, role=role, extra=extra or None)
|
|
175
|
+
|
|
176
|
+
def create_refresh_token(self, subject: str) -> str:
|
|
177
|
+
return self.jwt.create_refresh_token(subject)
|
|
178
|
+
|
|
179
|
+
def decode_token(self, token: str) -> dict[str, Any]:
|
|
180
|
+
return self.jwt.decode_token(token)
|
|
181
|
+
|
|
182
|
+
def verify_token(self, token: str) -> bool:
|
|
183
|
+
return self.jwt.verify_token(token)
|
softauth/core/config.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Runtime configuration for SoftAuth.
|
|
2
|
+
|
|
3
|
+
All fields can be overridden by keyword arguments to ``SoftAuth()``.
|
|
4
|
+
Environment variables are picked up automatically when ``from_env()`` is used.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import Literal, Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, field_validator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SoftAuthConfig(BaseModel):
|
|
16
|
+
"""Immutable configuration object passed through the entire library.
|
|
17
|
+
|
|
18
|
+
Prefer constructing via ``SoftAuth(...)`` rather than directly.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
model_config = {"frozen": True}
|
|
22
|
+
|
|
23
|
+
# ── Core security ──────────────────────────────────────────────────────
|
|
24
|
+
secret_key: str = Field(..., min_length=16)
|
|
25
|
+
algorithm: str = Field("HS256")
|
|
26
|
+
|
|
27
|
+
# ── Token lifetimes ────────────────────────────────────────────────────
|
|
28
|
+
access_expiry_minutes: int = Field(15, gt=0)
|
|
29
|
+
refresh_expiry_days: int = Field(7, gt=0)
|
|
30
|
+
|
|
31
|
+
# ── Framework adapter ──────────────────────────────────────────────────
|
|
32
|
+
# None means "framework-agnostic / manual" — useful for testing or custom
|
|
33
|
+
# adapters. Built-in values: "fastapi", "flask". Unknown strings are
|
|
34
|
+
# accepted here so that use_adapter() + framework=None works, and so that
|
|
35
|
+
# typos produce a ConfigurationError in init_app() rather than a Pydantic
|
|
36
|
+
# ValidationError at construction time.
|
|
37
|
+
framework: Optional[str] = None
|
|
38
|
+
|
|
39
|
+
# ── Database ───────────────────────────────────────────────────────────
|
|
40
|
+
database_url: str = Field("sqlite:///auth.db")
|
|
41
|
+
|
|
42
|
+
# ── Routing ────────────────────────────────────────────────────────────
|
|
43
|
+
auth_prefix: str = Field("/auth")
|
|
44
|
+
token_prefix: str = Field("Bearer")
|
|
45
|
+
|
|
46
|
+
# ── Behaviour flags ────────────────────────────────────────────────────
|
|
47
|
+
enable_refresh_tokens: bool = True
|
|
48
|
+
|
|
49
|
+
@field_validator("secret_key")
|
|
50
|
+
@classmethod
|
|
51
|
+
def _secret_not_placeholder(cls, v: str) -> str:
|
|
52
|
+
if v.lower() in {"secret", "changeme", "change-me", "your-secret-key"}:
|
|
53
|
+
import warnings
|
|
54
|
+
warnings.warn(
|
|
55
|
+
"SoftAuth secret_key looks like a placeholder. "
|
|
56
|
+
"Use a strong random value in production.",
|
|
57
|
+
stacklevel=2,
|
|
58
|
+
)
|
|
59
|
+
return v
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_env(cls, **overrides: object) -> "SoftAuthConfig":
|
|
63
|
+
"""Build config from environment variables, with keyword overrides."""
|
|
64
|
+
env: dict[str, object] = {
|
|
65
|
+
"secret_key": os.environ.get("SOFTAUTH_SECRET", ""),
|
|
66
|
+
"database_url": os.environ.get("SOFTAUTH_DB_URL", "sqlite:///auth.db"),
|
|
67
|
+
"algorithm": os.environ.get("SOFTAUTH_ALGORITHM", "HS256"),
|
|
68
|
+
}
|
|
69
|
+
env.update(overrides)
|
|
70
|
+
return cls(**env) # type: ignore[arg-type]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Hierarchy of SoftAuth exceptions.
|
|
2
|
+
|
|
3
|
+
Every exception the library raises is a subclass of SoftAuthError so callers
|
|
4
|
+
can catch the whole tree with a single ``except SoftAuthError`` if they need to.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SoftAuthError(Exception):
|
|
9
|
+
"""Root exception for all softauth errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ── Authentication ────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
class AuthError(SoftAuthError):
|
|
15
|
+
"""Raised for general authentication failures."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvalidCredentialsError(AuthError):
|
|
19
|
+
"""Wrong email / password combination."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InactiveUserError(AuthError):
|
|
23
|
+
"""Account exists but has been deactivated."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── Token ─────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
class TokenError(SoftAuthError):
|
|
29
|
+
"""Base class for all token-related errors."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TokenExpiredError(TokenError):
|
|
33
|
+
"""JWT has passed its expiry timestamp."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InvalidTokenError(TokenError):
|
|
37
|
+
"""JWT signature is bad, format is wrong, or claims are missing."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TokenTypeMismatchError(TokenError):
|
|
41
|
+
"""e.g. a refresh token was presented where an access token is expected."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── User / identity ───────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
class UserNotFoundError(SoftAuthError):
|
|
47
|
+
"""No user record matches the given identifier."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class UserAlreadyExistsError(SoftAuthError):
|
|
51
|
+
"""A user with this email already exists."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── Authorisation ─────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
class PermissionDeniedError(SoftAuthError):
|
|
57
|
+
"""Authenticated user lacks the required role."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── Configuration ─────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
class ConfigurationError(SoftAuthError):
|
|
63
|
+
"""Invalid or missing SoftAuth configuration."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AdapterNotInitializedError(SoftAuthError):
|
|
67
|
+
"""init_app() has not been called yet."""
|