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.
Files changed (43) hide show
  1. softauth/__init__.py +73 -0
  2. softauth/cli/__init__.py +3 -0
  3. softauth/cli/commands.py +233 -0
  4. softauth/core/__init__.py +35 -0
  5. softauth/core/auth.py +183 -0
  6. softauth/core/config.py +70 -0
  7. softauth/core/exceptions.py +67 -0
  8. softauth/database/__init__.py +5 -0
  9. softauth/database/models.py +58 -0
  10. softauth/database/repository.py +46 -0
  11. softauth/database/session.py +66 -0
  12. softauth/django/__init__.py +3 -0
  13. softauth/django/adapter.py +77 -0
  14. softauth/django/decorators.py +68 -0
  15. softauth/django/middleware.py +59 -0
  16. softauth/django/urls.py +36 -0
  17. softauth/django/views.py +139 -0
  18. softauth/fastapi/__init__.py +6 -0
  19. softauth/fastapi/adapter.py +55 -0
  20. softauth/fastapi/dependencies.py +107 -0
  21. softauth/fastapi/middleware.py +54 -0
  22. softauth/fastapi/routes.py +147 -0
  23. softauth/flask/__init__.py +6 -0
  24. softauth/flask/adapter.py +63 -0
  25. softauth/flask/decorators.py +73 -0
  26. softauth/flask/middleware.py +39 -0
  27. softauth/flask/routes.py +118 -0
  28. softauth/interfaces/__init__.py +12 -0
  29. softauth/interfaces/adapter.py +48 -0
  30. softauth/interfaces/auth_provider.py +34 -0
  31. softauth/interfaces/token_store.py +37 -0
  32. softauth/interfaces/user_store.py +39 -0
  33. softauth/jwt/__init__.py +4 -0
  34. softauth/jwt/handler.py +99 -0
  35. softauth/jwt/schemas.py +37 -0
  36. softauth/security/__init__.py +3 -0
  37. softauth/security/password.py +32 -0
  38. softauth-0.1.0.dist-info/METADATA +307 -0
  39. softauth-0.1.0.dist-info/RECORD +43 -0
  40. softauth-0.1.0.dist-info/WHEEL +5 -0
  41. softauth-0.1.0.dist-info/entry_points.txt +2 -0
  42. softauth-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. 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
+ ]
@@ -0,0 +1,3 @@
1
+ from softauth.cli.commands import app
2
+
3
+ __all__ = ["app"]
@@ -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)
@@ -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."""
@@ -0,0 +1,5 @@
1
+ from softauth.database.models import Base, User
2
+ from softauth.database.repository import UserRepository
3
+ from softauth.database.session import DatabaseSession
4
+
5
+ __all__ = ["Base", "User", "UserRepository", "DatabaseSession"]