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,67 @@
|
|
|
1
|
+
"""Passport-style OAuth2 + JWT authentication for FastAPI.
|
|
2
|
+
|
|
3
|
+
Public API mirrors the config-driven guard/provider/passwords model of Laravel
|
|
4
|
+
Passport while staying idiomatic FastAPI:
|
|
5
|
+
|
|
6
|
+
from fastapi_startkit_auth import Application, AuthProvider, AuthConfig
|
|
7
|
+
|
|
8
|
+
Attributes are resolved lazily so importing a single submodule never forces the
|
|
9
|
+
whole package to load.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__version__ = "0.2.0"
|
|
17
|
+
|
|
18
|
+
_EXPORTS = {
|
|
19
|
+
"AuthConfig": ("fastapi_startkit_auth.config", "AuthConfig"),
|
|
20
|
+
"AuthProvider": ("fastapi_startkit_auth.provider", "AuthProvider"),
|
|
21
|
+
"Application": ("fastapi_startkit_auth.application", "Application"),
|
|
22
|
+
"AuthManager": ("fastapi_startkit_auth.manager", "AuthManager"),
|
|
23
|
+
"current_user": ("fastapi_startkit_auth.dependencies", "current_user"),
|
|
24
|
+
"optional_user": ("fastapi_startkit_auth.dependencies", "optional_user"),
|
|
25
|
+
"require_scopes": ("fastapi_startkit_auth.dependencies", "require_scopes"),
|
|
26
|
+
"require_abilities": ("fastapi_startkit_auth.dependencies", "require_abilities"),
|
|
27
|
+
"AuthError": ("fastapi_startkit_auth.exceptions", "AuthError"),
|
|
28
|
+
"InvalidGrant": ("fastapi_startkit_auth.exceptions", "InvalidGrant"),
|
|
29
|
+
"InvalidClient": ("fastapi_startkit_auth.exceptions", "InvalidClient"),
|
|
30
|
+
"InvalidToken": ("fastapi_startkit_auth.exceptions", "InvalidToken"),
|
|
31
|
+
"InsufficientScope": ("fastapi_startkit_auth.exceptions", "InsufficientScope"),
|
|
32
|
+
"InvalidSession": ("fastapi_startkit_auth.exceptions", "InvalidSession"),
|
|
33
|
+
"CsrfTokenMismatch": ("fastapi_startkit_auth.exceptions", "CsrfTokenMismatch"),
|
|
34
|
+
"Auth": ("fastapi_startkit_auth.facade", "Auth"),
|
|
35
|
+
"SessionGuard": ("fastapi_startkit_auth.guards.session", "SessionGuard"),
|
|
36
|
+
"SessionMiddleware": ("fastapi_startkit_auth.middleware.session", "SessionMiddleware"),
|
|
37
|
+
"CsrfMiddleware": ("fastapi_startkit_auth.middleware.csrf", "CsrfMiddleware"),
|
|
38
|
+
"AuthServiceProvider": ("fastapi_startkit_auth.startkit", "AuthServiceProvider"),
|
|
39
|
+
"SessionRecord": ("fastapi_startkit_auth.sessions.models", "SessionRecord"),
|
|
40
|
+
"SessionStore": ("fastapi_startkit_auth.sessions.store", "SessionStore"),
|
|
41
|
+
"InMemorySessionStore": ("fastapi_startkit_auth.sessions.store", "InMemorySessionStore"),
|
|
42
|
+
"SqlSessionStore": ("fastapi_startkit_auth.sessions.sql", "SqlSessionStore"),
|
|
43
|
+
"TokenGuard": ("fastapi_startkit_auth.guards.token", "TokenGuard"),
|
|
44
|
+
"ApiTokenManager": ("fastapi_startkit_auth.apitokens.manager", "ApiTokenManager"),
|
|
45
|
+
"NewApiToken": ("fastapi_startkit_auth.apitokens.manager", "NewApiToken"),
|
|
46
|
+
"ApiTokenRecord": ("fastapi_startkit_auth.apitokens.models", "ApiTokenRecord"),
|
|
47
|
+
"ApiTokenRepository": ("fastapi_startkit_auth.apitokens.repository", "ApiTokenRepository"),
|
|
48
|
+
"InMemoryApiTokenRepository": (
|
|
49
|
+
"fastapi_startkit_auth.apitokens.repository",
|
|
50
|
+
"InMemoryApiTokenRepository",
|
|
51
|
+
),
|
|
52
|
+
"SqlApiTokenRepository": ("fastapi_startkit_auth.apitokens.sql", "SqlApiTokenRepository"),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
__all__ = list(_EXPORTS)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def __getattr__(name: str) -> Any:
|
|
59
|
+
try:
|
|
60
|
+
module_name, attr = _EXPORTS[name]
|
|
61
|
+
except KeyError:
|
|
62
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
|
63
|
+
return getattr(importlib.import_module(module_name), attr)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def __dir__() -> list[str]:
|
|
67
|
+
return sorted(__all__)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .manager import ApiTokenManager, NewApiToken
|
|
2
|
+
from .models import ApiTokenRecord
|
|
3
|
+
from .repository import ApiTokenRepository, InMemoryApiTokenRepository
|
|
4
|
+
from .sql import SqlApiTokenRepository
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ApiTokenManager",
|
|
8
|
+
"ApiTokenRecord",
|
|
9
|
+
"ApiTokenRepository",
|
|
10
|
+
"InMemoryApiTokenRepository",
|
|
11
|
+
"NewApiToken",
|
|
12
|
+
"SqlApiTokenRepository",
|
|
13
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..exceptions import InvalidToken
|
|
10
|
+
from .models import ApiTokenRecord
|
|
11
|
+
from .repository import ApiTokenRepository, generate_token_secret, hash_token_secret
|
|
12
|
+
|
|
13
|
+
# Every verification failure raises this exact message so responses (and their
|
|
14
|
+
# timing, via the dummy compare below) never reveal whether the id exists.
|
|
15
|
+
_GENERIC_FAILURE = "The API token is invalid."
|
|
16
|
+
|
|
17
|
+
_DUMMY_HASH = hash_token_secret("!" + generate_token_secret())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(repr=False)
|
|
21
|
+
class NewApiToken:
|
|
22
|
+
"""The one and only carrier of a token's plaintext.
|
|
23
|
+
|
|
24
|
+
``plain_text`` (``"{id}|{secret}"``) exists here and nowhere else — the
|
|
25
|
+
repository stores only the secret's hash, so it cannot be shown again.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
record: ApiTokenRecord
|
|
29
|
+
plain_text: str
|
|
30
|
+
|
|
31
|
+
def __repr__(self) -> str:
|
|
32
|
+
# The dataclass auto-repr would leak the live secret into logs,
|
|
33
|
+
# error-tracker locals, and test output; show only the id half.
|
|
34
|
+
return (
|
|
35
|
+
f"NewApiToken(record={self.record!r}, plain_text='{self.record.id}|***redacted***')"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ApiTokenManager:
|
|
40
|
+
"""Issues, verifies, revokes, and lists opaque API tokens.
|
|
41
|
+
|
|
42
|
+
Exposed as ``AuthManager.api_tokens``; apps wire their own management
|
|
43
|
+
routes around it (the package ships none, per the app-owns-routes
|
|
44
|
+
decision).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
repository: ApiTokenRepository,
|
|
50
|
+
default_ttl: float | None = None,
|
|
51
|
+
purge_interval: float = 300,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.repository = repository
|
|
54
|
+
self._default_ttl = default_ttl
|
|
55
|
+
self._purge_interval = purge_interval
|
|
56
|
+
self._purge_lock = threading.Lock()
|
|
57
|
+
self._last_purge = time.time()
|
|
58
|
+
|
|
59
|
+
def _maybe_purge(self) -> None:
|
|
60
|
+
"""Drop expired records periodically so stores stay bounded without a
|
|
61
|
+
scheduler (mirrors the purge-on-issue pattern of ``TokenService``)."""
|
|
62
|
+
now = time.time()
|
|
63
|
+
with self._purge_lock:
|
|
64
|
+
if now - self._last_purge < self._purge_interval:
|
|
65
|
+
return
|
|
66
|
+
self._last_purge = now
|
|
67
|
+
self.repository.purge_expired()
|
|
68
|
+
|
|
69
|
+
def create(
|
|
70
|
+
self,
|
|
71
|
+
user_id: Any,
|
|
72
|
+
name: str | None = None,
|
|
73
|
+
abilities: list[str] | None = None,
|
|
74
|
+
expires_at: float | None = None,
|
|
75
|
+
) -> NewApiToken:
|
|
76
|
+
"""Issue a token for ``user_id``; the plaintext is returned exactly once.
|
|
77
|
+
|
|
78
|
+
``expires_at`` overrides the configured default ttl; ``None`` with no
|
|
79
|
+
configured ttl means the token never expires. ``abilities`` default to
|
|
80
|
+
the unrestricted ``["*"]``.
|
|
81
|
+
"""
|
|
82
|
+
self._maybe_purge()
|
|
83
|
+
if expires_at is None and self._default_ttl is not None:
|
|
84
|
+
expires_at = time.time() + self._default_ttl
|
|
85
|
+
secret = generate_token_secret()
|
|
86
|
+
record = self.repository.create(
|
|
87
|
+
user_id=user_id,
|
|
88
|
+
token_hash=hash_token_secret(secret),
|
|
89
|
+
name=name,
|
|
90
|
+
abilities=list(abilities) if abilities else ["*"],
|
|
91
|
+
expires_at=expires_at,
|
|
92
|
+
)
|
|
93
|
+
return NewApiToken(record=record, plain_text=f"{record.id}|{secret}")
|
|
94
|
+
|
|
95
|
+
def verify(self, token: str) -> ApiTokenRecord:
|
|
96
|
+
"""Resolve ``"{id}|{secret}"`` to its live record or raise 401.
|
|
97
|
+
|
|
98
|
+
Lookup is by id (O(1), no scan); the secret comparison is constant-time
|
|
99
|
+
against the stored hash — or against a dummy hash when the id is
|
|
100
|
+
unknown, so the miss path costs the same as a mismatch.
|
|
101
|
+
"""
|
|
102
|
+
token_id, sep, secret = token.partition("|")
|
|
103
|
+
expected = _DUMMY_HASH
|
|
104
|
+
record = None
|
|
105
|
+
if sep and token_id and secret:
|
|
106
|
+
record = self.repository.find(token_id)
|
|
107
|
+
if record is not None:
|
|
108
|
+
expected = record.token_hash
|
|
109
|
+
matched = secrets.compare_digest(hash_token_secret(secret), expected)
|
|
110
|
+
if record is None or not matched or record.expired:
|
|
111
|
+
raise InvalidToken(_GENERIC_FAILURE)
|
|
112
|
+
self.repository.touch(record.id)
|
|
113
|
+
return record
|
|
114
|
+
|
|
115
|
+
def revoke(self, token_id: str) -> bool:
|
|
116
|
+
return self.repository.revoke(token_id)
|
|
117
|
+
|
|
118
|
+
def revoke_all(self, user_id: Any) -> int:
|
|
119
|
+
return self.repository.revoke_all_for_user(user_id)
|
|
120
|
+
|
|
121
|
+
def tokens_for(self, user_id: Any) -> list[ApiTokenRecord]:
|
|
122
|
+
return self.repository.list_for_user(user_id)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ApiTokenRecord:
|
|
10
|
+
"""Server-side record backing one opaque API token.
|
|
11
|
+
|
|
12
|
+
The caller holds ``"{id}|{secret}"``; this record stores only the SHA-256
|
|
13
|
+
hex digest of the secret, so a leaked store cannot mint valid tokens.
|
|
14
|
+
``abilities`` reuse the scope vocabulary (``["*"]`` = unrestricted), so
|
|
15
|
+
``require_scopes`` works unchanged on token-authenticated requests.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
id: str
|
|
19
|
+
user_id: Any
|
|
20
|
+
token_hash: str
|
|
21
|
+
name: str | None = None
|
|
22
|
+
abilities: list[str] = field(default_factory=lambda: ["*"])
|
|
23
|
+
last_used_at: float | None = None
|
|
24
|
+
expires_at: float | None = None
|
|
25
|
+
created_at: float = field(default_factory=lambda: time.time())
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def expired(self) -> bool:
|
|
29
|
+
return self.expires_at is not None and self.expires_at < time.time()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import secrets
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from .models import ApiTokenRecord
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_token_id() -> str:
|
|
12
|
+
return secrets.token_urlsafe(16)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate_token_secret() -> str:
|
|
16
|
+
return secrets.token_urlsafe(40)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def hash_token_secret(secret: str) -> str:
|
|
20
|
+
"""SHA-256 at rest: correct for high-entropy random secrets (bcrypt would
|
|
21
|
+
add per-request latency without adding security for non-guessable input)."""
|
|
22
|
+
return hashlib.sha256(secret.encode()).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@runtime_checkable
|
|
26
|
+
class ApiTokenRepository(Protocol):
|
|
27
|
+
"""Contract every API-token backend satisfies.
|
|
28
|
+
|
|
29
|
+
Mirrors the ``SessionStore`` pattern: the package ships an in-memory
|
|
30
|
+
default and a SQL implementation, and apps may implement the same surface
|
|
31
|
+
against any other store. Implementations must never see or persist the
|
|
32
|
+
plaintext secret — only its hash arrives here.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def create(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
user_id: Any,
|
|
39
|
+
token_hash: str,
|
|
40
|
+
name: str | None,
|
|
41
|
+
abilities: list[str],
|
|
42
|
+
expires_at: float | None,
|
|
43
|
+
) -> ApiTokenRecord:
|
|
44
|
+
"""Persist and return a new token record with a fresh id."""
|
|
45
|
+
|
|
46
|
+
def find(self, token_id: str) -> ApiTokenRecord | None:
|
|
47
|
+
"""Return the live record for this id, or ``None`` if unknown/expired."""
|
|
48
|
+
|
|
49
|
+
def touch(self, token_id: str) -> None:
|
|
50
|
+
"""Record usage (updates ``last_used_at``)."""
|
|
51
|
+
|
|
52
|
+
def revoke(self, token_id: str) -> bool:
|
|
53
|
+
"""Delete the record (revocation is row deletion); returns whether it existed."""
|
|
54
|
+
|
|
55
|
+
def revoke_all_for_user(self, user_id: Any) -> int:
|
|
56
|
+
"""Delete every token belonging to ``user_id``; returns how many."""
|
|
57
|
+
|
|
58
|
+
def list_for_user(self, user_id: Any) -> list[ApiTokenRecord]:
|
|
59
|
+
"""Return the user's live (non-expired) token records."""
|
|
60
|
+
|
|
61
|
+
def purge_expired(self) -> None:
|
|
62
|
+
"""Drop expired records (maintenance)."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InMemoryApiTokenRepository:
|
|
66
|
+
"""Default dict-backed token repository for tests, demos, and prototypes."""
|
|
67
|
+
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self._tokens: dict[str, ApiTokenRecord] = {}
|
|
70
|
+
|
|
71
|
+
def create(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
user_id: Any,
|
|
75
|
+
token_hash: str,
|
|
76
|
+
name: str | None,
|
|
77
|
+
abilities: list[str],
|
|
78
|
+
expires_at: float | None,
|
|
79
|
+
) -> ApiTokenRecord:
|
|
80
|
+
record = ApiTokenRecord(
|
|
81
|
+
id=generate_token_id(),
|
|
82
|
+
user_id=user_id,
|
|
83
|
+
token_hash=token_hash,
|
|
84
|
+
name=name,
|
|
85
|
+
abilities=list(abilities),
|
|
86
|
+
expires_at=expires_at,
|
|
87
|
+
)
|
|
88
|
+
self._tokens[record.id] = record
|
|
89
|
+
return record
|
|
90
|
+
|
|
91
|
+
def find(self, token_id: str) -> ApiTokenRecord | None:
|
|
92
|
+
record = self._tokens.get(token_id)
|
|
93
|
+
if record is None:
|
|
94
|
+
return None
|
|
95
|
+
if record.expired:
|
|
96
|
+
del self._tokens[token_id]
|
|
97
|
+
return None
|
|
98
|
+
return record
|
|
99
|
+
|
|
100
|
+
def touch(self, token_id: str) -> None:
|
|
101
|
+
record = self._tokens.get(token_id)
|
|
102
|
+
if record is not None:
|
|
103
|
+
record.last_used_at = time.time()
|
|
104
|
+
|
|
105
|
+
def revoke(self, token_id: str) -> bool:
|
|
106
|
+
return self._tokens.pop(token_id, None) is not None
|
|
107
|
+
|
|
108
|
+
def revoke_all_for_user(self, user_id: Any) -> int:
|
|
109
|
+
doomed = [tid for tid, rec in self._tokens.items() if rec.user_id == user_id]
|
|
110
|
+
for tid in doomed:
|
|
111
|
+
del self._tokens[tid]
|
|
112
|
+
return len(doomed)
|
|
113
|
+
|
|
114
|
+
def list_for_user(self, user_id: Any) -> list[ApiTokenRecord]:
|
|
115
|
+
return [
|
|
116
|
+
rec for rec in self._tokens.values() if rec.user_id == user_id and not rec.expired
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
def purge_expired(self) -> None:
|
|
120
|
+
self._tokens = {tid: rec for tid, rec in self._tokens.items() if not rec.expired}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .models import ApiTokenRecord
|
|
9
|
+
from .repository import generate_token_id
|
|
10
|
+
|
|
11
|
+
_SCHEMA = """
|
|
12
|
+
CREATE TABLE IF NOT EXISTS {table} (
|
|
13
|
+
id TEXT PRIMARY KEY,
|
|
14
|
+
user_id TEXT,
|
|
15
|
+
token_hash TEXT NOT NULL,
|
|
16
|
+
name TEXT,
|
|
17
|
+
abilities TEXT NOT NULL,
|
|
18
|
+
last_used_at REAL,
|
|
19
|
+
expires_at REAL,
|
|
20
|
+
created_at REAL NOT NULL
|
|
21
|
+
)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
_COLUMNS = "id, user_id, token_hash, name, abilities, last_used_at, expires_at, created_at"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SqlApiTokenRepository:
|
|
28
|
+
"""Persistent :class:`~.repository.ApiTokenRepository` backed by a
|
|
29
|
+
``personal_api_tokens`` table.
|
|
30
|
+
|
|
31
|
+
Works with any DB-API 2.0 connection using ``qmark`` placeholders (sqlite3
|
|
32
|
+
out of the box). ``user_id`` and ``abilities`` are JSON-encoded so
|
|
33
|
+
identifier types and ability lists round-trip exactly, matching the
|
|
34
|
+
in-memory repository. A lock serialises access because FastAPI runs sync
|
|
35
|
+
endpoints in a thread pool.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
connection: Any,
|
|
41
|
+
table: str = "personal_api_tokens",
|
|
42
|
+
create_table: bool = True,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._conn = connection
|
|
45
|
+
self._table = table
|
|
46
|
+
self._lock = threading.Lock()
|
|
47
|
+
if create_table:
|
|
48
|
+
with self._lock:
|
|
49
|
+
self._conn.execute(_SCHEMA.format(table=table))
|
|
50
|
+
self._conn.commit()
|
|
51
|
+
|
|
52
|
+
def create(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
user_id: Any,
|
|
56
|
+
token_hash: str,
|
|
57
|
+
name: str | None,
|
|
58
|
+
abilities: list[str],
|
|
59
|
+
expires_at: float | None,
|
|
60
|
+
) -> ApiTokenRecord:
|
|
61
|
+
record = ApiTokenRecord(
|
|
62
|
+
id=generate_token_id(),
|
|
63
|
+
user_id=user_id,
|
|
64
|
+
token_hash=token_hash,
|
|
65
|
+
name=name,
|
|
66
|
+
abilities=list(abilities),
|
|
67
|
+
expires_at=expires_at,
|
|
68
|
+
)
|
|
69
|
+
with self._lock:
|
|
70
|
+
self._conn.execute(
|
|
71
|
+
f"INSERT INTO {self._table} ({_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
72
|
+
(
|
|
73
|
+
record.id,
|
|
74
|
+
json.dumps(record.user_id),
|
|
75
|
+
record.token_hash,
|
|
76
|
+
record.name,
|
|
77
|
+
json.dumps(record.abilities),
|
|
78
|
+
record.last_used_at,
|
|
79
|
+
record.expires_at,
|
|
80
|
+
record.created_at,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
self._conn.commit()
|
|
84
|
+
return record
|
|
85
|
+
|
|
86
|
+
def find(self, token_id: str) -> ApiTokenRecord | None:
|
|
87
|
+
with self._lock:
|
|
88
|
+
row = self._conn.execute(
|
|
89
|
+
f"SELECT {_COLUMNS} FROM {self._table} WHERE id = ?", (token_id,)
|
|
90
|
+
).fetchone()
|
|
91
|
+
if row is None:
|
|
92
|
+
return None
|
|
93
|
+
record = self._to_record(row)
|
|
94
|
+
if record.expired:
|
|
95
|
+
self.revoke(token_id)
|
|
96
|
+
return None
|
|
97
|
+
return record
|
|
98
|
+
|
|
99
|
+
def touch(self, token_id: str) -> None:
|
|
100
|
+
with self._lock:
|
|
101
|
+
self._conn.execute(
|
|
102
|
+
f"UPDATE {self._table} SET last_used_at = ? WHERE id = ?",
|
|
103
|
+
(time.time(), token_id),
|
|
104
|
+
)
|
|
105
|
+
self._conn.commit()
|
|
106
|
+
|
|
107
|
+
def revoke(self, token_id: str) -> bool:
|
|
108
|
+
with self._lock:
|
|
109
|
+
cursor = self._conn.execute(
|
|
110
|
+
f"DELETE FROM {self._table} WHERE id = ?", (token_id,)
|
|
111
|
+
)
|
|
112
|
+
self._conn.commit()
|
|
113
|
+
return cursor.rowcount > 0
|
|
114
|
+
|
|
115
|
+
def revoke_all_for_user(self, user_id: Any) -> int:
|
|
116
|
+
with self._lock:
|
|
117
|
+
cursor = self._conn.execute(
|
|
118
|
+
f"DELETE FROM {self._table} WHERE user_id = ?", (json.dumps(user_id),)
|
|
119
|
+
)
|
|
120
|
+
self._conn.commit()
|
|
121
|
+
return cursor.rowcount
|
|
122
|
+
|
|
123
|
+
def list_for_user(self, user_id: Any) -> list[ApiTokenRecord]:
|
|
124
|
+
now = time.time()
|
|
125
|
+
with self._lock:
|
|
126
|
+
rows = self._conn.execute(
|
|
127
|
+
f"SELECT {_COLUMNS} FROM {self._table} "
|
|
128
|
+
"WHERE user_id = ? AND (expires_at IS NULL OR expires_at >= ?)",
|
|
129
|
+
(json.dumps(user_id), now),
|
|
130
|
+
).fetchall()
|
|
131
|
+
return [self._to_record(row) for row in rows]
|
|
132
|
+
|
|
133
|
+
def purge_expired(self) -> None:
|
|
134
|
+
with self._lock:
|
|
135
|
+
self._conn.execute(
|
|
136
|
+
f"DELETE FROM {self._table} WHERE expires_at IS NOT NULL AND expires_at < ?",
|
|
137
|
+
(time.time(),),
|
|
138
|
+
)
|
|
139
|
+
self._conn.commit()
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _to_record(row: Any) -> ApiTokenRecord:
|
|
143
|
+
return ApiTokenRecord(
|
|
144
|
+
id=row[0],
|
|
145
|
+
user_id=json.loads(row[1]),
|
|
146
|
+
token_hash=row[2],
|
|
147
|
+
name=row[3],
|
|
148
|
+
abilities=json.loads(row[4]),
|
|
149
|
+
last_used_at=row[5],
|
|
150
|
+
expires_at=row[6],
|
|
151
|
+
created_at=row[7],
|
|
152
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Application:
|
|
9
|
+
"""Thin composition root mirroring ``Application([(AuthProvider, AuthConfig)])``.
|
|
10
|
+
|
|
11
|
+
Creates a FastAPI app and registers each ``(ProviderClass, config)`` pair
|
|
12
|
+
onto it. The resulting FastAPI instance is exposed as ``.api`` and the
|
|
13
|
+
Application is itself ASGI-callable, so it can be served directly::
|
|
14
|
+
|
|
15
|
+
app = Application([(AuthProvider, AuthConfig)])
|
|
16
|
+
# uvicorn app:app -> uses Application.__call__
|
|
17
|
+
# uvicorn app:app.api -> uses the FastAPI instance
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
providers: Iterable[tuple[type, Any]] | None = None,
|
|
23
|
+
api: FastAPI | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.api = api or FastAPI(title="FastAPI Startkit Auth")
|
|
26
|
+
self.providers: list[Any] = []
|
|
27
|
+
for provider_cls, config in providers or []:
|
|
28
|
+
provider = provider_cls(config)
|
|
29
|
+
provider.register(self.api)
|
|
30
|
+
self.providers.append(provider)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def auth(self):
|
|
34
|
+
"""Return the AuthManager of the first registered provider."""
|
|
35
|
+
return self.providers[0].manager if self.providers else None
|
|
36
|
+
|
|
37
|
+
async def __call__(self, scope, receive, send):
|
|
38
|
+
await self.api(scope, receive, send)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Client:
|
|
8
|
+
"""An OAuth2 client (application).
|
|
9
|
+
|
|
10
|
+
Confidential clients authenticate with a secret; public clients (SPAs, native
|
|
11
|
+
apps) do not and must use the authorization-code + PKCE flow. An empty
|
|
12
|
+
``grant_types`` means the client may use any supported grant.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
id: str
|
|
16
|
+
name: str
|
|
17
|
+
secret: str | None = None
|
|
18
|
+
redirect_uris: list[str] = field(default_factory=list)
|
|
19
|
+
confidential: bool = True
|
|
20
|
+
grant_types: list[str] = field(default_factory=list)
|
|
21
|
+
revoked: bool = False
|
|
22
|
+
|
|
23
|
+
def allows_redirect(self, uri: str) -> bool:
|
|
24
|
+
return uri in self.redirect_uris
|
|
25
|
+
|
|
26
|
+
def allows_grant(self, grant_type: str) -> bool:
|
|
27
|
+
if not self.grant_types:
|
|
28
|
+
return True
|
|
29
|
+
return grant_type in self.grant_types
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
from ..security.hashing import BcryptHasher, Hasher
|
|
7
|
+
from .models import Client
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InMemoryClientRepository:
|
|
11
|
+
"""Client registry with hashed-secret storage.
|
|
12
|
+
|
|
13
|
+
``register`` returns the plaintext secret exactly once (mirroring how real
|
|
14
|
+
OAuth servers surface credentials); only its hash is retained.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, hasher: Hasher | None = None) -> None:
|
|
18
|
+
self._hasher = hasher or BcryptHasher()
|
|
19
|
+
self._clients: dict[str, Client] = {}
|
|
20
|
+
|
|
21
|
+
def register(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
name: str,
|
|
25
|
+
redirect_uris: list[str] | None = None,
|
|
26
|
+
confidential: bool = True,
|
|
27
|
+
grant_types: list[str] | None = None,
|
|
28
|
+
) -> tuple[Client, str | None]:
|
|
29
|
+
client_id = uuid.uuid4().hex
|
|
30
|
+
plain_secret: str | None = None
|
|
31
|
+
stored_secret: str | None = None
|
|
32
|
+
if confidential:
|
|
33
|
+
plain_secret = secrets.token_urlsafe(40)
|
|
34
|
+
stored_secret = self._hasher.make(plain_secret)
|
|
35
|
+
client = Client(
|
|
36
|
+
id=client_id,
|
|
37
|
+
name=name,
|
|
38
|
+
secret=stored_secret,
|
|
39
|
+
redirect_uris=list(redirect_uris or []),
|
|
40
|
+
confidential=confidential,
|
|
41
|
+
grant_types=list(grant_types or []),
|
|
42
|
+
)
|
|
43
|
+
self._clients[client_id] = client
|
|
44
|
+
return client, plain_secret
|
|
45
|
+
|
|
46
|
+
def add(self, client: Client) -> Client:
|
|
47
|
+
self._clients[client.id] = client
|
|
48
|
+
return client
|
|
49
|
+
|
|
50
|
+
def find(self, client_id: str) -> Client | None:
|
|
51
|
+
return self._clients.get(client_id)
|
|
52
|
+
|
|
53
|
+
def all(self) -> list[Client]:
|
|
54
|
+
return list(self._clients.values())
|
|
55
|
+
|
|
56
|
+
def authenticate(self, client_id: str, secret: str | None) -> Client | None:
|
|
57
|
+
client = self._clients.get(client_id)
|
|
58
|
+
if client is None or client.revoked:
|
|
59
|
+
return None
|
|
60
|
+
if not client.confidential:
|
|
61
|
+
return client
|
|
62
|
+
if secret is None or client.secret is None:
|
|
63
|
+
return None
|
|
64
|
+
if self._hasher.verify(secret, client.secret):
|
|
65
|
+
return client
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def delete(self, client_id: str) -> bool:
|
|
69
|
+
return self._clients.pop(client_id, None) is not None
|