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.
Files changed (57) hide show
  1. fastapi_startkit_auth/__init__.py +67 -0
  2. fastapi_startkit_auth/apitokens/__init__.py +13 -0
  3. fastapi_startkit_auth/apitokens/manager.py +122 -0
  4. fastapi_startkit_auth/apitokens/models.py +29 -0
  5. fastapi_startkit_auth/apitokens/repository.py +120 -0
  6. fastapi_startkit_auth/apitokens/sql.py +152 -0
  7. fastapi_startkit_auth/application.py +38 -0
  8. fastapi_startkit_auth/clients/__init__.py +4 -0
  9. fastapi_startkit_auth/clients/models.py +29 -0
  10. fastapi_startkit_auth/clients/repository.py +69 -0
  11. fastapi_startkit_auth/config.py +103 -0
  12. fastapi_startkit_auth/dependencies.py +93 -0
  13. fastapi_startkit_auth/exceptions.py +90 -0
  14. fastapi_startkit_auth/facade.py +107 -0
  15. fastapi_startkit_auth/grants/__init__.py +13 -0
  16. fastapi_startkit_auth/grants/authorization_code.py +83 -0
  17. fastapi_startkit_auth/grants/client_credentials.py +19 -0
  18. fastapi_startkit_auth/grants/password.py +32 -0
  19. fastapi_startkit_auth/grants/pkce.py +27 -0
  20. fastapi_startkit_auth/grants/refresh.py +13 -0
  21. fastapi_startkit_auth/guards/__init__.py +5 -0
  22. fastapi_startkit_auth/guards/guard.py +85 -0
  23. fastapi_startkit_auth/guards/session.py +81 -0
  24. fastapi_startkit_auth/guards/token.py +58 -0
  25. fastapi_startkit_auth/manager.py +320 -0
  26. fastapi_startkit_auth/middleware/__init__.py +4 -0
  27. fastapi_startkit_auth/middleware/csrf.py +160 -0
  28. fastapi_startkit_auth/middleware/session.py +117 -0
  29. fastapi_startkit_auth/passwords/__init__.py +4 -0
  30. fastapi_startkit_auth/passwords/broker.py +69 -0
  31. fastapi_startkit_auth/passwords/repository.py +44 -0
  32. fastapi_startkit_auth/provider.py +69 -0
  33. fastapi_startkit_auth/providers/__init__.py +4 -0
  34. fastapi_startkit_auth/providers/base.py +38 -0
  35. fastapi_startkit_auth/providers/memory.py +69 -0
  36. fastapi_startkit_auth/providers/model.py +70 -0
  37. fastapi_startkit_auth/publishable/cors.py +44 -0
  38. fastapi_startkit_auth/py.typed +0 -0
  39. fastapi_startkit_auth/routes.py +362 -0
  40. fastapi_startkit_auth/routes_spa.py +34 -0
  41. fastapi_startkit_auth/security/__init__.py +4 -0
  42. fastapi_startkit_auth/security/hashing.py +43 -0
  43. fastapi_startkit_auth/security/jwt.py +49 -0
  44. fastapi_startkit_auth/sessions/__init__.py +5 -0
  45. fastapi_startkit_auth/sessions/models.py +29 -0
  46. fastapi_startkit_auth/sessions/sql.py +158 -0
  47. fastapi_startkit_auth/sessions/state.py +11 -0
  48. fastapi_startkit_auth/sessions/store.py +100 -0
  49. fastapi_startkit_auth/startkit.py +38 -0
  50. fastapi_startkit_auth/tokens/__init__.py +9 -0
  51. fastapi_startkit_auth/tokens/models.py +68 -0
  52. fastapi_startkit_auth/tokens/repository.py +129 -0
  53. fastapi_startkit_auth/tokens/service.py +189 -0
  54. fastapi_startkit_auth-0.3.0.dist-info/METADATA +243 -0
  55. fastapi_startkit_auth-0.3.0.dist-info/RECORD +57 -0
  56. fastapi_startkit_auth-0.3.0.dist-info/WHEEL +4 -0
  57. 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,5 @@
1
+ from .guard import AuthContext, Guard, PassportGuard
2
+ from .session import SessionGuard
3
+ from .token import TokenGuard
4
+
5
+ __all__ = ["AuthContext", "Guard", "PassportGuard", "SessionGuard", "TokenGuard"]
@@ -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