fastapi-startkit-auth 0.3.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.
- fastapi_startkit_auth/__init__.py +67 -0
- fastapi_startkit_auth/apitokens/__init__.py +13 -0
- fastapi_startkit_auth/apitokens/manager.py +122 -0
- fastapi_startkit_auth/apitokens/models.py +29 -0
- fastapi_startkit_auth/apitokens/repository.py +120 -0
- fastapi_startkit_auth/apitokens/sql.py +152 -0
- fastapi_startkit_auth/application.py +38 -0
- fastapi_startkit_auth/clients/__init__.py +4 -0
- fastapi_startkit_auth/clients/models.py +29 -0
- fastapi_startkit_auth/clients/repository.py +69 -0
- fastapi_startkit_auth/config.py +103 -0
- fastapi_startkit_auth/dependencies.py +93 -0
- fastapi_startkit_auth/exceptions.py +90 -0
- fastapi_startkit_auth/facade.py +107 -0
- fastapi_startkit_auth/grants/__init__.py +13 -0
- fastapi_startkit_auth/grants/authorization_code.py +83 -0
- fastapi_startkit_auth/grants/client_credentials.py +19 -0
- fastapi_startkit_auth/grants/password.py +32 -0
- fastapi_startkit_auth/grants/pkce.py +27 -0
- fastapi_startkit_auth/grants/refresh.py +13 -0
- fastapi_startkit_auth/guards/__init__.py +5 -0
- fastapi_startkit_auth/guards/guard.py +85 -0
- fastapi_startkit_auth/guards/session.py +81 -0
- fastapi_startkit_auth/guards/token.py +58 -0
- fastapi_startkit_auth/manager.py +320 -0
- fastapi_startkit_auth/middleware/__init__.py +4 -0
- fastapi_startkit_auth/middleware/csrf.py +160 -0
- fastapi_startkit_auth/middleware/session.py +117 -0
- fastapi_startkit_auth/passwords/__init__.py +4 -0
- fastapi_startkit_auth/passwords/broker.py +69 -0
- fastapi_startkit_auth/passwords/repository.py +44 -0
- fastapi_startkit_auth/provider.py +69 -0
- fastapi_startkit_auth/providers/__init__.py +4 -0
- fastapi_startkit_auth/providers/base.py +38 -0
- fastapi_startkit_auth/providers/memory.py +69 -0
- fastapi_startkit_auth/providers/model.py +70 -0
- fastapi_startkit_auth/publishable/cors.py +44 -0
- fastapi_startkit_auth/py.typed +0 -0
- fastapi_startkit_auth/routes.py +362 -0
- fastapi_startkit_auth/routes_spa.py +34 -0
- fastapi_startkit_auth/security/__init__.py +4 -0
- fastapi_startkit_auth/security/hashing.py +43 -0
- fastapi_startkit_auth/security/jwt.py +49 -0
- fastapi_startkit_auth/sessions/__init__.py +5 -0
- fastapi_startkit_auth/sessions/models.py +29 -0
- fastapi_startkit_auth/sessions/sql.py +158 -0
- fastapi_startkit_auth/sessions/state.py +11 -0
- fastapi_startkit_auth/sessions/store.py +100 -0
- fastapi_startkit_auth/startkit.py +38 -0
- fastapi_startkit_auth/tokens/__init__.py +9 -0
- fastapi_startkit_auth/tokens/models.py +68 -0
- fastapi_startkit_auth/tokens/repository.py +129 -0
- fastapi_startkit_auth/tokens/service.py +189 -0
- fastapi_startkit_auth-0.3.0.dist-info/METADATA +243 -0
- fastapi_startkit_auth-0.3.0.dist-info/RECORD +57 -0
- fastapi_startkit_auth-0.3.0.dist-info/WHEEL +4 -0
- fastapi_startkit_auth-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuthConfig:
|
|
7
|
+
"""Config-driven auth definition, mirroring Laravel's ``config/auth.php``.
|
|
8
|
+
|
|
9
|
+
Subclass and override the class attributes. ``default``/``guards``/
|
|
10
|
+
``providers``/``passwords`` match the Passport model; the remaining
|
|
11
|
+
attributes tune JWT signing and token lifetimes and all have sane defaults so
|
|
12
|
+
a minimal config only needs to set ``key`` and a provider.
|
|
13
|
+
|
|
14
|
+
Example::
|
|
15
|
+
|
|
16
|
+
class AuthConfig(BaseAuthConfig):
|
|
17
|
+
key = os.environ["APP_KEY"]
|
|
18
|
+
default = {"guard": "api", "passwords": "users"}
|
|
19
|
+
guards = {"api": {"driver": "passport", "provider": "users"}}
|
|
20
|
+
providers = {"users": {"driver": "masoniteorm", "model": User}}
|
|
21
|
+
passwords = {"users": {"provider": "users", "table": "password_reset_tokens",
|
|
22
|
+
"expire": 60, "throttle": 60}}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
default: dict[str, str] = {"guard": "api", "passwords": "users"}
|
|
26
|
+
guards: dict[str, dict[str, Any]] = {"api": {"driver": "passport", "provider": "users"}}
|
|
27
|
+
providers: dict[str, dict[str, Any]] = {}
|
|
28
|
+
passwords: dict[str, dict[str, Any]] = {}
|
|
29
|
+
|
|
30
|
+
# --- session settings (cookie auth; used by {"driver": "session"} guards) --
|
|
31
|
+
# ``store`` selects the backend: "memory" (default), "sql" (requires a
|
|
32
|
+
# DB-API ``connection`` — or zero-arg factory — plus optional ``table``),
|
|
33
|
+
# or "instance" (a ready-made SessionStore under ``instance``). Overrides
|
|
34
|
+
# are merged over these defaults, so partial dicts are fine.
|
|
35
|
+
# ``purge_interval`` throttles the SQL store's opportunistic purge-on-create
|
|
36
|
+
# (seconds between sweeps of expired rows).
|
|
37
|
+
session: dict[str, Any] = {
|
|
38
|
+
"store": "memory",
|
|
39
|
+
"cookie": "startkit_session",
|
|
40
|
+
"ttl": 7200,
|
|
41
|
+
"idle_ttl": None,
|
|
42
|
+
"http_only": True,
|
|
43
|
+
"same_site": "lax",
|
|
44
|
+
"secure": True,
|
|
45
|
+
"domain": None,
|
|
46
|
+
"path": "/",
|
|
47
|
+
"purge_interval": 300,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# --- SPA settings (CSRF protection on top of session guards) -------
|
|
51
|
+
# ``enabled`` turns on the ``/__auth__/csrf-cookie`` endpoint and the
|
|
52
|
+
# double-submit CSRF middleware. ``stateful_origins`` (Sanctum-style) lists
|
|
53
|
+
# the first-party origins allowed to make cookie-authenticated unsafe
|
|
54
|
+
# requests; empty disables the Origin check. ``csrf_exempt_paths`` entries
|
|
55
|
+
# match exactly, or as a prefix when they end with ``*``.
|
|
56
|
+
spa: dict[str, Any] = {
|
|
57
|
+
"enabled": False,
|
|
58
|
+
"csrf_cookie": "XSRF-TOKEN",
|
|
59
|
+
"csrf_header": "X-XSRF-TOKEN",
|
|
60
|
+
"csrf_exempt_paths": [],
|
|
61
|
+
"stateful_origins": [],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# --- API token settings (opaque tokens; used by {"driver": "token"} guards) --
|
|
65
|
+
# ``store`` selects the backend like the session block: "memory" (default),
|
|
66
|
+
# "sql" (requires a DB-API ``connection`` — or zero-arg factory — plus
|
|
67
|
+
# optional ``table``), or "instance" (a ready-made ApiTokenRepository under
|
|
68
|
+
# ``instance``). ``header`` is where the guard reads the token: the default
|
|
69
|
+
# "Authorization" expects a ``Bearer`` scheme, any other name is read raw.
|
|
70
|
+
# ``ttl`` is the default token lifetime in seconds (None = non-expiring);
|
|
71
|
+
# ``purge_interval`` throttles the opportunistic purge-on-issue.
|
|
72
|
+
api_tokens: dict[str, Any] = {
|
|
73
|
+
"store": "memory",
|
|
74
|
+
"header": "Authorization",
|
|
75
|
+
"ttl": None,
|
|
76
|
+
"purge_interval": 300,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# --- JWT / token settings (overridable) ----------------------------
|
|
80
|
+
key: str | None = None
|
|
81
|
+
algorithm: str = "HS256"
|
|
82
|
+
access_token_ttl: int = 3600 # 1 hour
|
|
83
|
+
refresh_token_ttl: int = 60 * 60 * 24 * 14 # 14 days
|
|
84
|
+
personal_access_token_ttl: int = 60 * 60 * 24 * 365 # 1 year
|
|
85
|
+
authorization_code_ttl: int = 600 # 10 minutes
|
|
86
|
+
bcrypt_rounds: int = 12
|
|
87
|
+
|
|
88
|
+
# Called as ``notifier(email, token)`` when a reset link is requested so the
|
|
89
|
+
# app can deliver the token (e.g. email it). The plaintext token is NEVER put
|
|
90
|
+
# in the HTTP response unless ``debug_expose_reset_token`` is explicitly True.
|
|
91
|
+
password_reset_notifier: Any = None
|
|
92
|
+
debug_expose_reset_token: bool = False
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def get(cls, name: str, default: Any = None) -> Any:
|
|
96
|
+
return getattr(cls, name, default)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def as_config_class(config: Any) -> type[AuthConfig]:
|
|
100
|
+
"""Accept either an ``AuthConfig`` subclass or an instance and return a class-like accessor."""
|
|
101
|
+
if isinstance(config, type):
|
|
102
|
+
return config
|
|
103
|
+
return config.__class__
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, Request
|
|
6
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
7
|
+
|
|
8
|
+
from .exceptions import AuthError, InsufficientScope, InvalidToken
|
|
9
|
+
from .guards.guard import AuthContext
|
|
10
|
+
from .manager import AuthManager
|
|
11
|
+
|
|
12
|
+
# auto_error=False so we can raise our own OAuth2-style JSON errors instead of
|
|
13
|
+
# FastAPI's default {"detail": ...} response.
|
|
14
|
+
_bearer = OAuth2PasswordBearer(tokenUrl="oauth/token", auto_error=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_auth_manager(request: Request) -> AuthManager:
|
|
18
|
+
manager = getattr(request.app.state, "auth_manager", None)
|
|
19
|
+
if manager is None: # pragma: no cover - misconfiguration guard
|
|
20
|
+
raise RuntimeError(
|
|
21
|
+
"No AuthManager on the application. Did you register AuthProvider via Application?"
|
|
22
|
+
)
|
|
23
|
+
return manager
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def current_context(
|
|
27
|
+
request: Request,
|
|
28
|
+
token: str | None = Depends(_bearer),
|
|
29
|
+
manager: AuthManager = Depends(get_auth_manager),
|
|
30
|
+
) -> AuthContext:
|
|
31
|
+
"""Resolve the request credential to an :class:`AuthContext` or raise 401.
|
|
32
|
+
|
|
33
|
+
The default guard decides the mechanism: guards that implement
|
|
34
|
+
``authenticate(request)`` (session driver) read the request/cookie state,
|
|
35
|
+
everything else takes the bearer-token path. Route code is identical either
|
|
36
|
+
way.
|
|
37
|
+
"""
|
|
38
|
+
guard = manager.guard()
|
|
39
|
+
authenticate = getattr(guard, "authenticate", None)
|
|
40
|
+
if authenticate is not None:
|
|
41
|
+
return authenticate(request)
|
|
42
|
+
if not token:
|
|
43
|
+
raise InvalidToken("Not authenticated.")
|
|
44
|
+
return guard.user_from_token(token)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def current_user(context: AuthContext = Depends(current_context)) -> Any:
|
|
48
|
+
"""Return the authenticated user model (401 if the token carries no user)."""
|
|
49
|
+
if context.user is None:
|
|
50
|
+
raise InvalidToken("This token is not associated with a user.")
|
|
51
|
+
return context.user
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def optional_user(
|
|
55
|
+
request: Request,
|
|
56
|
+
token: str | None = Depends(_bearer),
|
|
57
|
+
manager: AuthManager = Depends(get_auth_manager),
|
|
58
|
+
) -> Any | None:
|
|
59
|
+
"""Return the authenticated user, or ``None`` if unauthenticated/invalid."""
|
|
60
|
+
guard = manager.guard()
|
|
61
|
+
authenticate = getattr(guard, "authenticate", None)
|
|
62
|
+
try:
|
|
63
|
+
if authenticate is not None:
|
|
64
|
+
return authenticate(request).user
|
|
65
|
+
if not token:
|
|
66
|
+
return None
|
|
67
|
+
return guard.user_from_token(token).user
|
|
68
|
+
except AuthError:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def require_scopes(*scopes: str, mode: str = "all") -> Callable[..., AuthContext]:
|
|
73
|
+
"""Dependency factory enforcing token abilities (Passport-style scopes).
|
|
74
|
+
|
|
75
|
+
``mode="all"`` requires every scope; ``mode="any"`` requires at least one.
|
|
76
|
+
Raises 403 ``insufficient_scope`` when the token lacks them.
|
|
77
|
+
"""
|
|
78
|
+
required = list(scopes)
|
|
79
|
+
|
|
80
|
+
def dependency(context: AuthContext = Depends(current_context)) -> AuthContext:
|
|
81
|
+
ok = context.can_any(required) if mode == "any" else context.can_all(required)
|
|
82
|
+
if not ok:
|
|
83
|
+
raise InsufficientScope(
|
|
84
|
+
f"Requires scope(s): {' '.join(required)} (mode={mode})."
|
|
85
|
+
)
|
|
86
|
+
return context
|
|
87
|
+
|
|
88
|
+
return dependency
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Sanctum-flavored name for the same check: API-token abilities live in
|
|
92
|
+
# ``AuthContext.scopes``, so scope and ability enforcement are one mechanism.
|
|
93
|
+
require_abilities = require_scopes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Auth error hierarchy.
|
|
2
|
+
|
|
3
|
+
Error codes follow the OAuth2 error registry (RFC 6749 §5.2) so they can be
|
|
4
|
+
surfaced verbatim in token-endpoint responses.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthError(Exception):
|
|
10
|
+
"""Base class for all authentication/authorization failures."""
|
|
11
|
+
|
|
12
|
+
error = "invalid_request"
|
|
13
|
+
status_code = 400
|
|
14
|
+
|
|
15
|
+
def __init__(self, description: str | None = None) -> None:
|
|
16
|
+
self.description = description or self.__class__.__doc__ or self.error
|
|
17
|
+
super().__init__(self.description)
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict:
|
|
20
|
+
return {"error": self.error, "error_description": self.description}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidRequest(AuthError):
|
|
24
|
+
"""The request is missing a required parameter or is otherwise malformed."""
|
|
25
|
+
|
|
26
|
+
error = "invalid_request"
|
|
27
|
+
status_code = 400
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InvalidClient(AuthError):
|
|
31
|
+
"""Client authentication failed (unknown client or bad secret)."""
|
|
32
|
+
|
|
33
|
+
error = "invalid_client"
|
|
34
|
+
status_code = 401
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InvalidGrant(AuthError):
|
|
38
|
+
"""The provided credentials, code, or refresh token are invalid or expired."""
|
|
39
|
+
|
|
40
|
+
error = "invalid_grant"
|
|
41
|
+
status_code = 400
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class UnsupportedGrantType(AuthError):
|
|
45
|
+
"""The authorization server does not support this grant type."""
|
|
46
|
+
|
|
47
|
+
error = "unsupported_grant_type"
|
|
48
|
+
status_code = 400
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class UnauthorizedClient(AuthError):
|
|
52
|
+
"""The client is not permitted to use this grant type."""
|
|
53
|
+
|
|
54
|
+
error = "unauthorized_client"
|
|
55
|
+
status_code = 400
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InvalidToken(AuthError):
|
|
59
|
+
"""The access token is expired, malformed, revoked, or otherwise invalid."""
|
|
60
|
+
|
|
61
|
+
error = "invalid_token"
|
|
62
|
+
status_code = 401
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InvalidSession(AuthError):
|
|
66
|
+
"""The session cookie is missing, expired, or no longer valid."""
|
|
67
|
+
|
|
68
|
+
error = "invalid_session"
|
|
69
|
+
status_code = 401
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CsrfTokenMismatch(AuthError):
|
|
73
|
+
"""The request is missing a valid CSRF token for this session."""
|
|
74
|
+
|
|
75
|
+
error = "csrf_token_mismatch"
|
|
76
|
+
status_code = 403
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class InsufficientScope(AuthError):
|
|
80
|
+
"""The token does not carry the scopes required for this resource."""
|
|
81
|
+
|
|
82
|
+
error = "insufficient_scope"
|
|
83
|
+
status_code = 403
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ThrottleException(AuthError):
|
|
87
|
+
"""Too many requests for this action; retry later."""
|
|
88
|
+
|
|
89
|
+
error = "throttled"
|
|
90
|
+
status_code = 429
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, Request
|
|
6
|
+
|
|
7
|
+
from .dependencies import get_auth_manager
|
|
8
|
+
from .exceptions import AuthError
|
|
9
|
+
from .guards.session import SessionGuard
|
|
10
|
+
from .manager import AuthManager
|
|
11
|
+
from .sessions.models import SessionRecord
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Auth:
|
|
15
|
+
"""Request-scoped facade over the manager and the request's session.
|
|
16
|
+
|
|
17
|
+
Wire it into your own login/logout routes (the package ships none)::
|
|
18
|
+
|
|
19
|
+
from fastapi_startkit_auth import Auth
|
|
20
|
+
|
|
21
|
+
@api.post("/login")
|
|
22
|
+
def login(payload: Credentials, auth: Auth = Depends(Auth.scoped)):
|
|
23
|
+
if not auth.attempt(payload.model_dump()):
|
|
24
|
+
raise InvalidSession("Invalid credentials.")
|
|
25
|
+
return {"ok": True}
|
|
26
|
+
|
|
27
|
+
@api.post("/logout")
|
|
28
|
+
def logout(auth: Auth = Depends(Auth.scoped)):
|
|
29
|
+
auth.logout()
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, manager: AuthManager, request: Request) -> None:
|
|
33
|
+
self._manager = manager
|
|
34
|
+
self._request = request
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def scoped(cls, request: Request, manager: AuthManager = Depends(get_auth_manager)) -> "Auth":
|
|
38
|
+
"""FastAPI dependency: ``auth: Auth = Depends(Auth.scoped)``."""
|
|
39
|
+
return cls(manager, request)
|
|
40
|
+
|
|
41
|
+
def guard(self, name: str | None = None) -> Any:
|
|
42
|
+
return self._manager.guard(name)
|
|
43
|
+
|
|
44
|
+
def login(self, user_or_id: Any, guard: str | None = None) -> SessionRecord:
|
|
45
|
+
"""Start a session for a user instance or a user id.
|
|
46
|
+
|
|
47
|
+
Always regenerates the session id (fixation protection) and rides the
|
|
48
|
+
middleware to deliver the cookie.
|
|
49
|
+
"""
|
|
50
|
+
session_guard = self._session_guard(guard)
|
|
51
|
+
user = self._resolve_user(user_or_id, session_guard)
|
|
52
|
+
return session_guard.login(self._request, user)
|
|
53
|
+
|
|
54
|
+
def attempt(self, credentials: dict[str, Any], guard: str | None = None) -> bool:
|
|
55
|
+
"""Log in by credentials; ``False`` on any failure (no enumeration)."""
|
|
56
|
+
session_guard = self._session_guard(guard)
|
|
57
|
+
provider = session_guard.provider
|
|
58
|
+
user = provider.retrieve_by_credentials(credentials)
|
|
59
|
+
if user is None:
|
|
60
|
+
dummy = getattr(provider, "dummy_verify", None)
|
|
61
|
+
if dummy is not None:
|
|
62
|
+
dummy()
|
|
63
|
+
return False
|
|
64
|
+
if not provider.validate_credentials(user, credentials):
|
|
65
|
+
return False
|
|
66
|
+
session_guard.login(self._request, user)
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
def logout(self, guard: str | None = None) -> None:
|
|
70
|
+
self._session_guard(guard).logout(self._request)
|
|
71
|
+
|
|
72
|
+
def user(self, guard: str | None = None) -> Any | None:
|
|
73
|
+
"""The authenticated user, or ``None`` — never raises."""
|
|
74
|
+
resolved = self.guard(guard)
|
|
75
|
+
authenticate = getattr(resolved, "authenticate", None)
|
|
76
|
+
if authenticate is None:
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
return authenticate(self._request).user
|
|
80
|
+
except AuthError:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def id(self, guard: str | None = None) -> Any | None:
|
|
84
|
+
user = self.user(guard)
|
|
85
|
+
if user is None:
|
|
86
|
+
return None
|
|
87
|
+
return self.guard(guard).provider.get_identifier(user)
|
|
88
|
+
|
|
89
|
+
def check(self, guard: str | None = None) -> bool:
|
|
90
|
+
return self.user(guard) is not None
|
|
91
|
+
|
|
92
|
+
def _session_guard(self, name: str | None) -> SessionGuard:
|
|
93
|
+
resolved = self.guard(name)
|
|
94
|
+
if not isinstance(resolved, SessionGuard):
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
f"Auth guard {resolved.name!r} is not a session guard; "
|
|
97
|
+
'login/logout require a {"driver": "session"} guard.'
|
|
98
|
+
)
|
|
99
|
+
return resolved
|
|
100
|
+
|
|
101
|
+
def _resolve_user(self, user_or_id: Any, session_guard: SessionGuard) -> Any:
|
|
102
|
+
if not isinstance(user_or_id, (int, str)):
|
|
103
|
+
return user_or_id
|
|
104
|
+
user = session_guard.provider.retrieve_by_id(user_or_id)
|
|
105
|
+
if user is None:
|
|
106
|
+
raise ValueError(f"No user with id {user_or_id!r} in guard {session_guard.name!r}'s provider.")
|
|
107
|
+
return user
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .authorization_code import AuthorizationCodeGrant
|
|
2
|
+
from .client_credentials import ClientCredentialsGrant
|
|
3
|
+
from .password import PasswordGrant
|
|
4
|
+
from .pkce import verify_pkce
|
|
5
|
+
from .refresh import RefreshTokenGrant
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"PasswordGrant",
|
|
9
|
+
"ClientCredentialsGrant",
|
|
10
|
+
"RefreshTokenGrant",
|
|
11
|
+
"AuthorizationCodeGrant",
|
|
12
|
+
"verify_pkce",
|
|
13
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..clients.models import Client
|
|
8
|
+
from ..exceptions import InvalidGrant, InvalidRequest
|
|
9
|
+
from ..tokens.service import IssuedToken, TokenService
|
|
10
|
+
from .pkce import verify_pkce
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthorizationCodeGrant:
|
|
14
|
+
"""Authorization-code grant with PKCE (RFC 6749 §4.1 + RFC 7636).
|
|
15
|
+
|
|
16
|
+
PKCE is mandatory for public (secretless) clients and for any exchange where
|
|
17
|
+
the client did not authenticate — both at code issuance (a public client
|
|
18
|
+
cannot obtain a code without a ``code_challenge``) and at exchange (the
|
|
19
|
+
verifier must match). Confidential clients that authenticate may omit PKCE,
|
|
20
|
+
but any challenge they do supply is still verified.
|
|
21
|
+
|
|
22
|
+
``issue_code`` is called after the resource owner approves a request;
|
|
23
|
+
``handle`` exchanges the resulting single-use code for tokens.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, token_service: TokenService, code_ttl: int = 600) -> None:
|
|
27
|
+
self._tokens = token_service
|
|
28
|
+
self._code_ttl = code_ttl
|
|
29
|
+
|
|
30
|
+
def issue_code(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
client: Client,
|
|
34
|
+
user_id: Any,
|
|
35
|
+
scopes: list[str],
|
|
36
|
+
redirect_uri: str | None,
|
|
37
|
+
code_challenge: str | None,
|
|
38
|
+
code_challenge_method: str | None,
|
|
39
|
+
) -> str:
|
|
40
|
+
if not client.confidential and not code_challenge:
|
|
41
|
+
raise InvalidRequest("A PKCE code_challenge is required for public clients.")
|
|
42
|
+
code = secrets.token_urlsafe(40)
|
|
43
|
+
self._tokens.repository.store_auth_code(
|
|
44
|
+
code=code,
|
|
45
|
+
client_id=client.id,
|
|
46
|
+
user_id=user_id,
|
|
47
|
+
scopes=scopes,
|
|
48
|
+
redirect_uri=redirect_uri,
|
|
49
|
+
code_challenge=code_challenge,
|
|
50
|
+
code_challenge_method=code_challenge_method,
|
|
51
|
+
expires_at=time.time() + self._code_ttl,
|
|
52
|
+
)
|
|
53
|
+
return code
|
|
54
|
+
|
|
55
|
+
def handle(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
client: Client,
|
|
59
|
+
code: str,
|
|
60
|
+
redirect_uri: str | None,
|
|
61
|
+
code_verifier: str | None,
|
|
62
|
+
client_authenticated: bool = False,
|
|
63
|
+
) -> IssuedToken:
|
|
64
|
+
record = self._tokens.repository.pull_auth_code(code)
|
|
65
|
+
if record is None or record.expired:
|
|
66
|
+
raise InvalidGrant("The authorization code is invalid or expired.")
|
|
67
|
+
if record.client_id != client.id:
|
|
68
|
+
raise InvalidGrant("The authorization code was issued to a different client.")
|
|
69
|
+
if record.redirect_uri != redirect_uri:
|
|
70
|
+
raise InvalidGrant("The redirect URI does not match the authorization request.")
|
|
71
|
+
# PKCE is mandatory unless the client proved its identity with a secret.
|
|
72
|
+
pkce_required = not client_authenticated
|
|
73
|
+
if pkce_required and not record.code_challenge:
|
|
74
|
+
raise InvalidGrant("PKCE is required: this code was not bound to a code_challenge.")
|
|
75
|
+
if record.code_challenge:
|
|
76
|
+
if not verify_pkce(code_verifier or "", record.code_challenge, record.code_challenge_method):
|
|
77
|
+
raise InvalidGrant("PKCE verification failed.")
|
|
78
|
+
return self._tokens.issue(
|
|
79
|
+
user_id=record.user_id,
|
|
80
|
+
client_id=client.id,
|
|
81
|
+
scopes=record.scopes,
|
|
82
|
+
with_refresh=True,
|
|
83
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..clients.models import Client
|
|
4
|
+
from ..tokens.service import IssuedToken, TokenService
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ClientCredentialsGrant:
|
|
8
|
+
"""Machine-to-machine grant (RFC 6749 §4.4). No user, no refresh token."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, token_service: TokenService) -> None:
|
|
11
|
+
self._tokens = token_service
|
|
12
|
+
|
|
13
|
+
def handle(self, *, client: Client, scopes: list[str]) -> IssuedToken:
|
|
14
|
+
return self._tokens.issue(
|
|
15
|
+
user_id=None,
|
|
16
|
+
client_id=client.id,
|
|
17
|
+
scopes=scopes,
|
|
18
|
+
with_refresh=False,
|
|
19
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..exceptions import InvalidGrant
|
|
4
|
+
from ..providers.base import UserProvider
|
|
5
|
+
from ..tokens.service import IssuedToken, TokenService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PasswordGrant:
|
|
9
|
+
"""Resource-owner password credentials grant (RFC 6749 §4.3)."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, token_service: TokenService, user_provider: UserProvider) -> None:
|
|
12
|
+
self._tokens = token_service
|
|
13
|
+
self._users = user_provider
|
|
14
|
+
|
|
15
|
+
def handle(self, *, username: str, password: str, scopes: list[str], client_id: str | None) -> IssuedToken:
|
|
16
|
+
credentials = {"username": username, "email": username, "password": password}
|
|
17
|
+
user = self._users.retrieve_by_credentials(credentials)
|
|
18
|
+
if user is None:
|
|
19
|
+
# Perform equivalent hashing work so response timing doesn't reveal
|
|
20
|
+
# whether the account exists, then fail with the same generic error.
|
|
21
|
+
dummy = getattr(self._users, "dummy_verify", None)
|
|
22
|
+
if callable(dummy):
|
|
23
|
+
dummy()
|
|
24
|
+
raise InvalidGrant("The provided credentials are incorrect.")
|
|
25
|
+
if not self._users.validate_credentials(user, credentials):
|
|
26
|
+
raise InvalidGrant("The provided credentials are incorrect.")
|
|
27
|
+
return self._tokens.issue(
|
|
28
|
+
user_id=self._users.get_identifier(user),
|
|
29
|
+
client_id=client_id,
|
|
30
|
+
scopes=scopes,
|
|
31
|
+
with_refresh=True,
|
|
32
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _b64url(data: bytes) -> str:
|
|
9
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def verify_pkce(code_verifier: str, code_challenge: str, method: str | None) -> bool:
|
|
13
|
+
"""Validate a PKCE ``code_verifier`` against a stored challenge (RFC 7636).
|
|
14
|
+
|
|
15
|
+
``S256`` compares the base64url-encoded SHA-256 of the verifier; ``plain``
|
|
16
|
+
(and a missing method) compares verbatim. Comparison is constant-time.
|
|
17
|
+
"""
|
|
18
|
+
if not code_verifier or not code_challenge:
|
|
19
|
+
return False
|
|
20
|
+
method = (method or "plain").upper()
|
|
21
|
+
if method == "S256":
|
|
22
|
+
expected = _b64url(hashlib.sha256(code_verifier.encode()).digest())
|
|
23
|
+
elif method == "PLAIN":
|
|
24
|
+
expected = code_verifier
|
|
25
|
+
else:
|
|
26
|
+
return False
|
|
27
|
+
return hmac.compare_digest(expected, code_challenge)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..tokens.service import IssuedToken, TokenService
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RefreshTokenGrant:
|
|
7
|
+
"""Refresh-token grant (RFC 6749 §6) with rotation handled by TokenService."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, token_service: TokenService) -> None:
|
|
10
|
+
self._tokens = token_service
|
|
11
|
+
|
|
12
|
+
def handle(self, *, refresh_token: str, scopes: list[str] | None) -> IssuedToken:
|
|
13
|
+
return self._tokens.refresh(refresh_token, scopes=scopes)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
from ..exceptions import InvalidToken
|
|
7
|
+
from ..providers.base import UserProvider
|
|
8
|
+
from ..tokens.service import TokenService
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class AuthContext:
|
|
13
|
+
"""The authenticated principal resolved from a bearer token.
|
|
14
|
+
|
|
15
|
+
``user`` is ``None`` for client-credentials tokens (machine-to-machine).
|
|
16
|
+
``can``/``can_any`` implement Passport-style ability checks against the
|
|
17
|
+
token's scopes.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
user: Any | None
|
|
21
|
+
scopes: list[str] = field(default_factory=list)
|
|
22
|
+
client_id: str | None = None
|
|
23
|
+
jti: str | None = None
|
|
24
|
+
|
|
25
|
+
def can(self, scope: str) -> bool:
|
|
26
|
+
return "*" in self.scopes or scope in self.scopes
|
|
27
|
+
|
|
28
|
+
def can_any(self, scopes: list[str]) -> bool:
|
|
29
|
+
return any(self.can(s) for s in scopes)
|
|
30
|
+
|
|
31
|
+
def can_all(self, scopes: list[str]) -> bool:
|
|
32
|
+
return all(self.can(s) for s in scopes)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class Guard(Protocol):
|
|
37
|
+
"""Structural contract every auth guard driver satisfies.
|
|
38
|
+
|
|
39
|
+
A guard pairs a named user provider with a mechanism for turning a request
|
|
40
|
+
credential into an :class:`AuthContext`. ``PassportGuard`` (JWT bearer),
|
|
41
|
+
``SessionGuard`` (cookie), and ``TokenGuard`` (opaque API token) are the
|
|
42
|
+
built-in drivers; app-defined drivers register their own factories with
|
|
43
|
+
:class:`~fastapi_startkit_auth.manager.AuthManager` and implement this same
|
|
44
|
+
surface.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
provider: UserProvider
|
|
49
|
+
|
|
50
|
+
def user_from_token(self, access_token: str) -> AuthContext: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PassportGuard:
|
|
54
|
+
"""Resolves a request's bearer token to an :class:`AuthContext`.
|
|
55
|
+
|
|
56
|
+
Corresponds to a ``{"driver": "passport"}`` guard entry: it pairs the shared
|
|
57
|
+
:class:`TokenService` (which validates signature, expiry, and revocation)
|
|
58
|
+
with the guard's configured user provider.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, name: str, token_service: TokenService, provider: UserProvider) -> None:
|
|
62
|
+
self.name = name
|
|
63
|
+
self._tokens = token_service
|
|
64
|
+
self.provider = provider
|
|
65
|
+
|
|
66
|
+
def user_from_token(self, access_token: str) -> AuthContext:
|
|
67
|
+
claims = self._tokens.authenticate(access_token)
|
|
68
|
+
user = self._resolve_user(claims.get("sub"))
|
|
69
|
+
return AuthContext(
|
|
70
|
+
user=user,
|
|
71
|
+
scopes=list(claims.get("scopes", [])),
|
|
72
|
+
client_id=claims.get("client_id"),
|
|
73
|
+
jti=claims.get("jti"),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _resolve_user(self, sub: Any) -> Any | None:
|
|
77
|
+
if sub is None:
|
|
78
|
+
return None
|
|
79
|
+
user = self.provider.retrieve_by_id(sub)
|
|
80
|
+
# JWT `sub` is always a string; retry with an int id for numeric keys.
|
|
81
|
+
if user is None and isinstance(sub, str) and sub.isdigit():
|
|
82
|
+
user = self.provider.retrieve_by_id(int(sub))
|
|
83
|
+
if user is None:
|
|
84
|
+
raise InvalidToken("The token subject no longer exists.")
|
|
85
|
+
return user
|