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
@@ -0,0 +1,58 @@
1
+ """Default SQLAlchemy 2.0 user model.
2
+
3
+ Developers can replace this model by implementing the UserStore interface.
4
+ No framework imports; pure SQLAlchemy.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+ from typing import Any
12
+
13
+ from sqlalchemy import Boolean, DateTime, String
14
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
15
+
16
+
17
+ class Base(DeclarativeBase):
18
+ pass
19
+
20
+
21
+ class User(Base):
22
+ """Default user table created by ``auth.init_db()``."""
23
+
24
+ __tablename__ = "softauth_users"
25
+
26
+ id: Mapped[str] = mapped_column(
27
+ String(36),
28
+ primary_key=True,
29
+ default=lambda: str(uuid.uuid4()),
30
+ )
31
+ email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
32
+ hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
33
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
34
+ role: Mapped[str] = mapped_column(String(50), default="user", nullable=False)
35
+ created_at: Mapped[datetime] = mapped_column(
36
+ DateTime(timezone=True),
37
+ default=lambda: datetime.now(timezone.utc),
38
+ nullable=False,
39
+ )
40
+ updated_at: Mapped[datetime] = mapped_column(
41
+ DateTime(timezone=True),
42
+ default=lambda: datetime.now(timezone.utc),
43
+ onupdate=lambda: datetime.now(timezone.utc),
44
+ nullable=False,
45
+ )
46
+
47
+ def to_dict(self) -> dict[str, Any]:
48
+ return {
49
+ "id": self.id,
50
+ "email": self.email,
51
+ "is_active": self.is_active,
52
+ "role": self.role,
53
+ "created_at": self.created_at.isoformat(),
54
+ "updated_at": self.updated_at.isoformat(),
55
+ }
56
+
57
+ def __repr__(self) -> str:
58
+ return f"<User id={self.id!r} email={self.email!r} role={self.role!r}>"
@@ -0,0 +1,46 @@
1
+ """UserRepository — synchronous CRUD over the default User model.
2
+
3
+ No framework imports. Accepts an open SQLAlchemy Session and performs
4
+ operations within the caller's transaction boundary.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ from sqlalchemy.orm import Session
12
+
13
+ from softauth.core.exceptions import UserAlreadyExistsError
14
+ from softauth.database.models import User
15
+
16
+
17
+ class UserRepository:
18
+ """Data-access object for the softauth_users table."""
19
+
20
+ def __init__(self, session: Session) -> None:
21
+ self._s = session
22
+
23
+ def get_by_id(self, user_id: str) -> Optional[User]:
24
+ return self._s.get(User, user_id)
25
+
26
+ def get_by_email(self, email: str) -> Optional[User]:
27
+ return self._s.query(User).filter(User.email == email).first()
28
+
29
+ def create(self, email: str, hashed_password: str, role: str = "user") -> User:
30
+ if self.get_by_email(email) is not None:
31
+ raise UserAlreadyExistsError(f"A user with email '{email}' already exists.")
32
+ user = User(email=email, hashed_password=hashed_password, role=role)
33
+ self._s.add(user)
34
+ self._s.flush() # populate user.id before the context manager commits
35
+ return user
36
+
37
+ def update(self, user: User) -> User:
38
+ self._s.add(user)
39
+ return user
40
+
41
+ def deactivate(self, user_id: str) -> Optional[User]:
42
+ user = self.get_by_id(user_id)
43
+ if user:
44
+ user.is_active = False
45
+ self._s.add(user)
46
+ return user
@@ -0,0 +1,66 @@
1
+ """SQLAlchemy session factory.
2
+
3
+ Wraps engine + SessionLocal into a single injectable object.
4
+ No framework imports.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextlib import contextmanager
10
+ from typing import Generator
11
+
12
+ from sqlalchemy import create_engine
13
+ from sqlalchemy.orm import Session, sessionmaker
14
+ from sqlalchemy.pool import StaticPool
15
+
16
+ from softauth.database.models import Base
17
+
18
+
19
+ class DatabaseSession:
20
+ """Thin wrapper around an SQLAlchemy engine and session factory.
21
+
22
+ ``expire_on_commit=False`` is intentional: detached User objects returned
23
+ from session contexts remain usable (all columns already loaded in memory).
24
+
25
+ For ``sqlite:///:memory:``, ``StaticPool`` is used so all connections share
26
+ the same in-memory database — essential for testing in threaded environments
27
+ such as FastAPI's TestClient.
28
+ """
29
+
30
+ def __init__(self, database_url: str) -> None:
31
+ connect_args: dict[str, object] = {}
32
+ kwargs: dict[str, object] = {}
33
+
34
+ if database_url.startswith("sqlite"):
35
+ connect_args["check_same_thread"] = False
36
+ if ":memory:" in database_url:
37
+ kwargs["poolclass"] = StaticPool
38
+
39
+ self._engine = create_engine(database_url, connect_args=connect_args, **kwargs) # type: ignore[arg-type]
40
+ self._factory = sessionmaker(
41
+ bind=self._engine,
42
+ autocommit=False,
43
+ autoflush=False,
44
+ expire_on_commit=False,
45
+ )
46
+
47
+ def create_tables(self) -> None:
48
+ """Create all softauth tables (idempotent)."""
49
+ Base.metadata.create_all(self._engine)
50
+
51
+ def drop_tables(self) -> None:
52
+ """Drop all softauth tables. Useful in tests."""
53
+ Base.metadata.drop_all(self._engine)
54
+
55
+ @contextmanager
56
+ def session(self) -> Generator[Session, None, None]:
57
+ """Yield a transactional session; commit on success, rollback on error."""
58
+ s = self._factory()
59
+ try:
60
+ yield s
61
+ s.commit()
62
+ except Exception:
63
+ s.rollback()
64
+ raise
65
+ finally:
66
+ s.close()
@@ -0,0 +1,3 @@
1
+ from softauth.django.adapter import DjangoAdapter
2
+
3
+ __all__ = ["DjangoAdapter"]
@@ -0,0 +1,77 @@
1
+ """Django adapter — implements BaseAdapter for Django applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Callable
6
+
7
+ from softauth.core.config import SoftAuthConfig
8
+ from softauth.interfaces.adapter import BaseAdapter
9
+
10
+ if TYPE_CHECKING:
11
+ from softauth.core.auth import SoftAuth
12
+
13
+
14
+ class DjangoAdapter(BaseAdapter):
15
+ """Wires SoftAuth into a Django application.
16
+
17
+ Responsibilities:
18
+ - Configures ``softauth.django.middleware.SoftAuthMiddleware`` via a
19
+ module-level singleton (called in ``on_startup()``).
20
+ - Appends auto-generated auth URL patterns to the provided ``urlpatterns``
21
+ list in ``init_app(urlpatterns)``.
22
+ - Exposes ``login_required``, ``admin_required``, and ``require_role()``
23
+ as standard Django view decorators.
24
+
25
+ Usage::
26
+
27
+ # settings.py
28
+ MIDDLEWARE = [
29
+ ...
30
+ "softauth.django.middleware.SoftAuthMiddleware",
31
+ ]
32
+
33
+ # urls.py
34
+ urlpatterns = []
35
+ auth.init_app(urlpatterns) # appends /auth/* patterns
36
+ """
37
+
38
+ def __init__(self, auth: "SoftAuth", config: SoftAuthConfig) -> None:
39
+ self._auth = auth
40
+ self._config = config
41
+
42
+ from softauth.django.decorators import DecoratorFactory
43
+ self._dec = DecoratorFactory(auth, config)
44
+
45
+ def on_startup(self) -> None:
46
+ """Configure the middleware singleton with this adapter's JWT handler."""
47
+ from softauth.django import middleware as mw
48
+ mw.configure(self._auth.jwt)
49
+
50
+ def init_app(self, urlpatterns: Any) -> None:
51
+ """Append softauth URL patterns to *urlpatterns* (a Django ``urlpatterns`` list)."""
52
+ from softauth.django.urls import create_auth_urlpatterns
53
+ urlpatterns += create_auth_urlpatterns(self._auth, self._config)
54
+
55
+ # ── BaseAdapter ────────────────────────────────────────────────────────────
56
+
57
+ def get_current_user_dependency(self) -> Callable[..., Any]:
58
+ return self._dec.login_required
59
+
60
+ def get_current_admin_dependency(self) -> Callable[..., Any]:
61
+ return self._dec.admin_required
62
+
63
+ def get_require_role_dependency(self, role: str) -> Callable[..., Any]:
64
+ return self._dec.require_role(role)
65
+
66
+ # ── Shortcut properties used by SoftAuth ───────────────────────────────────
67
+
68
+ @property
69
+ def login_required(self) -> Callable[..., Any]:
70
+ return self._dec.login_required
71
+
72
+ @property
73
+ def admin_required(self) -> Callable[..., Any]:
74
+ return self._dec.admin_required
75
+
76
+ def require_role(self, role: str) -> Callable[..., Any]:
77
+ return self._dec.require_role(role)
@@ -0,0 +1,68 @@
1
+ """Django decorators: @login_required, @admin_required, @require_role."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import wraps
6
+ from typing import TYPE_CHECKING, Any, Callable
7
+
8
+ from django.http import JsonResponse
9
+
10
+ from softauth.core.config import SoftAuthConfig
11
+
12
+ if TYPE_CHECKING:
13
+ from softauth.core.auth import SoftAuth
14
+
15
+
16
+ class DecoratorFactory:
17
+ """Builds Django view decorators that validate the JWT-populated request attributes."""
18
+
19
+ def __init__(self, auth: "SoftAuth", config: SoftAuthConfig) -> None:
20
+ self._auth = auth
21
+ self._config = config
22
+
23
+ def _load_user(self, request: Any) -> Any:
24
+ user_id = getattr(request, "softauth_user_id", None)
25
+ if not user_id:
26
+ return None
27
+ with self._auth._db.session() as s:
28
+ from softauth.database.repository import UserRepository
29
+ return UserRepository(s).get_by_id(user_id)
30
+
31
+ def login_required(self, fn: Callable[..., Any]) -> Callable[..., Any]:
32
+ """Require any authenticated active user."""
33
+ @wraps(fn)
34
+ def _wrapper(request: Any, *args: Any, **kwargs: Any) -> Any:
35
+ user = self._load_user(request)
36
+ if user is None or not user.is_active:
37
+ return JsonResponse({"error": "Authentication required"}, status=401)
38
+ request.softauth_user = user
39
+ return fn(request, *args, **kwargs)
40
+ return _wrapper
41
+
42
+ def admin_required(self, fn: Callable[..., Any]) -> Callable[..., Any]:
43
+ """Require an authenticated user with role == 'admin'."""
44
+ @wraps(fn)
45
+ def _wrapper(request: Any, *args: Any, **kwargs: Any) -> Any:
46
+ user = self._load_user(request)
47
+ if user is None or not user.is_active:
48
+ return JsonResponse({"error": "Authentication required"}, status=401)
49
+ if user.role != "admin":
50
+ return JsonResponse({"error": "Admin role required"}, status=403)
51
+ request.softauth_user = user
52
+ return fn(request, *args, **kwargs)
53
+ return _wrapper
54
+
55
+ def require_role(self, role: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
56
+ """Require an authenticated user with ``role`` (admin is always allowed)."""
57
+ def _decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
58
+ @wraps(fn)
59
+ def _wrapper(request: Any, *args: Any, **kwargs: Any) -> Any:
60
+ user = self._load_user(request)
61
+ if user is None or not user.is_active:
62
+ return JsonResponse({"error": "Authentication required"}, status=401)
63
+ if user.role not in (role, "admin"):
64
+ return JsonResponse({"error": f"Role '{role}' required"}, status=403)
65
+ request.softauth_user = user
66
+ return fn(request, *args, **kwargs)
67
+ return _wrapper
68
+ return _decorator
@@ -0,0 +1,59 @@
1
+ """Django middleware: populates request.softauth_* from JWT on every request.
2
+
3
+ Add to Django settings:
4
+ MIDDLEWARE = [
5
+ ...
6
+ "softauth.django.middleware.SoftAuthMiddleware",
7
+ ]
8
+
9
+ The middleware reads the jwt_handler configured by DjangoAdapter.on_startup().
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING, Any, Callable, Optional
15
+
16
+ from softauth.core.exceptions import TokenError
17
+
18
+ if TYPE_CHECKING:
19
+ from softauth.jwt.handler import JWTHandler
20
+
21
+ _jwt_handler: Optional["JWTHandler"] = None
22
+
23
+
24
+ def configure(jwt_handler: "JWTHandler") -> None:
25
+ """Register the JWTHandler used by SoftAuthMiddleware. Called by DjangoAdapter."""
26
+ global _jwt_handler
27
+ _jwt_handler = jwt_handler
28
+
29
+
30
+ class SoftAuthMiddleware:
31
+ """Parses JWT from Authorization header and populates request.softauth_* attributes.
32
+
33
+ Sets on every request (regardless of whether a token is present):
34
+ request.softauth_user_id — str | None
35
+ request.softauth_role — str | None
36
+ request.softauth_payload — dict | None
37
+ """
38
+
39
+ def __init__(self, get_response: Callable[..., Any]) -> None:
40
+ self.get_response = get_response
41
+
42
+ def __call__(self, request: Any) -> Any:
43
+ request.softauth_user_id = None
44
+ request.softauth_role = None
45
+ request.softauth_payload = None
46
+
47
+ if _jwt_handler is not None:
48
+ auth_header: str = request.META.get("HTTP_AUTHORIZATION", "")
49
+ if auth_header:
50
+ try:
51
+ token = _jwt_handler.extract_token_from_header(auth_header)
52
+ payload = _jwt_handler.decode_token(token)
53
+ request.softauth_user_id = payload.get("sub")
54
+ request.softauth_role = payload.get("role")
55
+ request.softauth_payload = payload
56
+ except TokenError:
57
+ pass
58
+
59
+ return self.get_response(request)
@@ -0,0 +1,36 @@
1
+ """URL pattern factory for softauth's Django auth endpoints.
2
+
3
+ Usage in your urls.py:
4
+ from django.urls import path, include
5
+ from softauth.django.urls import create_auth_urlpatterns
6
+
7
+ urlpatterns = [
8
+ # ... your routes
9
+ ] + create_auth_urlpatterns(auth, auth.config)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from softauth.core.config import SoftAuthConfig
17
+ from softauth.django.views import create_auth_views
18
+
19
+ if TYPE_CHECKING:
20
+ from softauth.core.auth import SoftAuth
21
+
22
+
23
+ def create_auth_urlpatterns(auth: "SoftAuth", config: SoftAuthConfig) -> list[Any]:
24
+ """Return Django URL patterns for all auth endpoints."""
25
+ from django.urls import path
26
+
27
+ views = create_auth_views(auth, config)
28
+ prefix = config.auth_prefix.strip("/")
29
+
30
+ return [
31
+ path(f"{prefix}/register/", views["register"], name="softauth-register"),
32
+ path(f"{prefix}/login/", views["login"], name="softauth-login"),
33
+ path(f"{prefix}/refresh/", views["refresh"], name="softauth-refresh"),
34
+ path(f"{prefix}/me/", views["me"], name="softauth-me"),
35
+ path(f"{prefix}/logout/", views["logout"], name="softauth-logout"),
36
+ ]
@@ -0,0 +1,139 @@
1
+ """Django view functions for the auto-generated auth endpoints.
2
+
3
+ Provides: POST /register POST /login POST /refresh GET /me POST /logout
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from django.http import JsonResponse
12
+ from django.views.decorators.csrf import csrf_exempt
13
+
14
+ from softauth.core.config import SoftAuthConfig
15
+ from softauth.core.exceptions import InvalidTokenError, TokenExpiredError, UserAlreadyExistsError
16
+
17
+ if TYPE_CHECKING:
18
+ from softauth.core.auth import SoftAuth
19
+
20
+
21
+ def create_auth_views(auth: "SoftAuth", config: SoftAuthConfig) -> dict[str, Any]:
22
+ """Return a dict of Django view callables, one per auth endpoint."""
23
+
24
+ def _body(request: Any) -> dict[str, Any]:
25
+ try:
26
+ return json.loads(request.body or b"{}") or {}
27
+ except (json.JSONDecodeError, ValueError):
28
+ return {}
29
+
30
+ @csrf_exempt
31
+ def register(request: Any) -> JsonResponse:
32
+ if request.method != "POST":
33
+ return JsonResponse({"error": "Method not allowed"}, status=405)
34
+ body = _body(request)
35
+ email: str = body.get("email", "")
36
+ password: str = body.get("password", "")
37
+ role: str = body.get("role", "user")
38
+
39
+ if not email or not password:
40
+ return JsonResponse({"error": "email and password are required"}, status=400)
41
+
42
+ try:
43
+ with auth._db.session() as s:
44
+ from softauth.database.repository import UserRepository
45
+ user = UserRepository(s).create(
46
+ email=email,
47
+ hashed_password=auth.passwords.hash_password(password),
48
+ role=role,
49
+ )
50
+ return JsonResponse({"message": "User registered successfully", "id": user.id}, status=201)
51
+ except UserAlreadyExistsError as exc:
52
+ return JsonResponse({"error": str(exc)}, status=409)
53
+
54
+ @csrf_exempt
55
+ def login(request: Any) -> JsonResponse:
56
+ if request.method != "POST":
57
+ return JsonResponse({"error": "Method not allowed"}, status=405)
58
+ body = _body(request)
59
+ email: str = body.get("email", "")
60
+ password: str = body.get("password", "")
61
+
62
+ with auth._db.session() as s:
63
+ from softauth.database.repository import UserRepository
64
+ user = UserRepository(s).get_by_email(email)
65
+
66
+ if user is None or not auth.passwords.verify_password(password, user.hashed_password):
67
+ return JsonResponse({"error": "Invalid credentials"}, status=401)
68
+ if not user.is_active:
69
+ return JsonResponse({"error": "Inactive user"}, status=400)
70
+
71
+ access_token = auth.jwt.create_access_token(subject=user.id, role=user.role)
72
+ refresh_token = auth.jwt.create_refresh_token(subject=user.id)
73
+
74
+ return JsonResponse({
75
+ "access_token": access_token,
76
+ "refresh_token": refresh_token,
77
+ "token_type": "bearer",
78
+ "expires_in": config.access_expiry_minutes * 60,
79
+ })
80
+
81
+ @csrf_exempt
82
+ def refresh(request: Any) -> JsonResponse:
83
+ if request.method != "POST":
84
+ return JsonResponse({"error": "Method not allowed"}, status=405)
85
+ body = _body(request)
86
+ token: str = body.get("refresh_token", "")
87
+
88
+ try:
89
+ payload = auth.jwt.decode_token(token)
90
+ except TokenExpiredError:
91
+ return JsonResponse({"error": "Refresh token expired"}, status=401)
92
+ except InvalidTokenError:
93
+ return JsonResponse({"error": "Invalid refresh token"}, status=401)
94
+
95
+ if payload.get("type") != "refresh":
96
+ return JsonResponse({"error": "Not a refresh token"}, status=401)
97
+
98
+ user_id: str = payload["sub"]
99
+ with auth._db.session() as s:
100
+ from softauth.database.repository import UserRepository
101
+ user = UserRepository(s).get_by_id(user_id)
102
+
103
+ if user is None or not user.is_active:
104
+ return JsonResponse({"error": "User not found or inactive"}, status=401)
105
+
106
+ new_token = auth.jwt.create_access_token(subject=user_id, role=user.role)
107
+ return JsonResponse({
108
+ "access_token": new_token,
109
+ "token_type": "bearer",
110
+ "expires_in": config.access_expiry_minutes * 60,
111
+ })
112
+
113
+ @csrf_exempt
114
+ def me(request: Any) -> JsonResponse:
115
+ if request.method != "GET":
116
+ return JsonResponse({"error": "Method not allowed"}, status=405)
117
+ user_id = getattr(request, "softauth_user_id", None)
118
+ if not user_id:
119
+ return JsonResponse({"error": "Authentication required"}, status=401)
120
+
121
+ with auth._db.session() as s:
122
+ from softauth.database.repository import UserRepository
123
+ user = UserRepository(s).get_by_id(user_id)
124
+
125
+ if user is None:
126
+ return JsonResponse({"error": "User not found"}, status=404)
127
+ return JsonResponse(user.to_dict())
128
+
129
+ @csrf_exempt
130
+ def logout(request: Any) -> JsonResponse:
131
+ return JsonResponse({"message": "Logged out successfully"})
132
+
133
+ return {
134
+ "register": register,
135
+ "login": login,
136
+ "refresh": refresh,
137
+ "me": me,
138
+ "logout": logout,
139
+ }
@@ -0,0 +1,6 @@
1
+ from softauth.fastapi.adapter import FastAPIAdapter
2
+ from softauth.fastapi.dependencies import DependencyFactory
3
+ from softauth.fastapi.middleware import JWTMiddleware
4
+ from softauth.fastapi.routes import create_auth_router
5
+
6
+ __all__ = ["FastAPIAdapter", "DependencyFactory", "JWTMiddleware", "create_auth_router"]
@@ -0,0 +1,55 @@
1
+ """FastAPI adapter — implements BaseAdapter for FastAPI applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Callable
6
+
7
+ from softauth.core.config import SoftAuthConfig
8
+ from softauth.interfaces.adapter import BaseAdapter
9
+
10
+ if TYPE_CHECKING:
11
+ from fastapi import FastAPI
12
+ from softauth.core.auth import SoftAuth
13
+
14
+
15
+ class FastAPIAdapter(BaseAdapter):
16
+ """Wires SoftAuth into a FastAPI application.
17
+
18
+ Responsibilities:
19
+ - Registers ``JWTMiddleware`` so ``request.state.user_*`` is always set.
20
+ - Mounts the auto-generated auth router under ``config.auth_prefix``.
21
+ - Exposes ``current_user``, ``current_admin``, and ``require_role()``
22
+ as FastAPI-native ``Depends()``-compatible callables.
23
+ """
24
+
25
+ def __init__(self, auth: "SoftAuth", config: SoftAuthConfig) -> None:
26
+ self._auth = auth
27
+ self._config = config
28
+
29
+ from softauth.fastapi.dependencies import DependencyFactory
30
+ self._deps = DependencyFactory(auth, config)
31
+
32
+ def init_app(self, app: "FastAPI") -> None:
33
+ from softauth.fastapi.middleware import JWTMiddleware
34
+ from softauth.fastapi.routes import create_auth_router
35
+
36
+ app.add_middleware(
37
+ JWTMiddleware,
38
+ config=self._config,
39
+ jwt_handler=self._auth.jwt,
40
+ )
41
+ router = create_auth_router(self._auth, self._config, self._deps)
42
+ app.include_router(
43
+ router,
44
+ prefix=self._config.auth_prefix,
45
+ tags=["Authentication"],
46
+ )
47
+
48
+ def get_current_user_dependency(self) -> Callable[..., Any]:
49
+ return self._deps.current_user
50
+
51
+ def get_current_admin_dependency(self) -> Callable[..., Any]:
52
+ return self._deps.current_admin
53
+
54
+ def get_require_role_dependency(self, role: str) -> Callable[..., Any]:
55
+ return self._deps.require_role(role)