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,11 @@
|
|
|
1
|
+
"""Request-state keys shared by the session middleware, guard, and facade.
|
|
2
|
+
|
|
3
|
+
The middleware loads the session record under ``SESSION_KEY`` before the app
|
|
4
|
+
runs and inspects the same keys afterwards to decide whether to set or delete
|
|
5
|
+
the cookie. ``LOADED_ID_KEY`` holds the session id that arrived on the request
|
|
6
|
+
(if any) so a changed id is detected; ``FORGET_KEY`` marks an explicit logout.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
SESSION_KEY = "auth_session"
|
|
10
|
+
LOADED_ID_KEY = "auth_session_loaded_id"
|
|
11
|
+
FORGET_KEY = "auth_session_forget"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from .models import SessionRecord
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def generate_session_id() -> str:
|
|
11
|
+
return secrets.token_urlsafe(32)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def generate_csrf_token() -> str:
|
|
15
|
+
return secrets.token_urlsafe(32)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class SessionStore(Protocol):
|
|
20
|
+
"""Contract every session backend satisfies.
|
|
21
|
+
|
|
22
|
+
Mirrors the repository pattern of ``InMemoryTokenRepository``: the package
|
|
23
|
+
ships an in-memory default and a SQL implementation, and apps may implement
|
|
24
|
+
the same surface against any other store.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def create(self, *, user_id: Any, guard: str, ttl: float | None) -> SessionRecord:
|
|
28
|
+
"""Persist and return a new session with a fresh id and CSRF token."""
|
|
29
|
+
|
|
30
|
+
def find(self, session_id: str) -> SessionRecord | None:
|
|
31
|
+
"""Return the live session for this id, or ``None`` if unknown/expired."""
|
|
32
|
+
|
|
33
|
+
def touch(self, session_id: str) -> None:
|
|
34
|
+
"""Record activity on the session (slides the idle-expiry window)."""
|
|
35
|
+
|
|
36
|
+
def regenerate_id(self, session_id: str) -> SessionRecord | None:
|
|
37
|
+
"""Re-key the session under a fresh id, invalidating the old id."""
|
|
38
|
+
|
|
39
|
+
def invalidate(self, session_id: str) -> bool:
|
|
40
|
+
"""Delete the session server-side; returns whether it existed."""
|
|
41
|
+
|
|
42
|
+
def purge_expired(self) -> None:
|
|
43
|
+
"""Drop expired sessions (maintenance)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InMemorySessionStore:
|
|
47
|
+
"""Default dict-backed session store for tests, demos, and prototypes.
|
|
48
|
+
|
|
49
|
+
``idle_ttl`` expires sessions that have been inactive longer than the
|
|
50
|
+
window, independently of the absolute ``expires_at`` set at creation.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, idle_ttl: float | None = None) -> None:
|
|
54
|
+
self._sessions: dict[str, SessionRecord] = {}
|
|
55
|
+
self._idle_ttl = idle_ttl
|
|
56
|
+
|
|
57
|
+
def create(self, *, user_id: Any, guard: str, ttl: float | None) -> SessionRecord:
|
|
58
|
+
now = time.time()
|
|
59
|
+
record = SessionRecord(
|
|
60
|
+
id=generate_session_id(),
|
|
61
|
+
user_id=user_id,
|
|
62
|
+
guard=guard,
|
|
63
|
+
csrf_token=generate_csrf_token(),
|
|
64
|
+
created_at=now,
|
|
65
|
+
last_activity=now,
|
|
66
|
+
expires_at=(now + ttl) if ttl is not None else None,
|
|
67
|
+
)
|
|
68
|
+
self._sessions[record.id] = record
|
|
69
|
+
return record
|
|
70
|
+
|
|
71
|
+
def find(self, session_id: str) -> SessionRecord | None:
|
|
72
|
+
record = self._sessions.get(session_id)
|
|
73
|
+
if record is None:
|
|
74
|
+
return None
|
|
75
|
+
if record.expired(self._idle_ttl):
|
|
76
|
+
del self._sessions[session_id]
|
|
77
|
+
return None
|
|
78
|
+
return record
|
|
79
|
+
|
|
80
|
+
def touch(self, session_id: str) -> None:
|
|
81
|
+
record = self._sessions.get(session_id)
|
|
82
|
+
if record is not None:
|
|
83
|
+
record.last_activity = time.time()
|
|
84
|
+
|
|
85
|
+
def regenerate_id(self, session_id: str) -> SessionRecord | None:
|
|
86
|
+
record = self.find(session_id)
|
|
87
|
+
if record is None:
|
|
88
|
+
return None
|
|
89
|
+
del self._sessions[session_id]
|
|
90
|
+
record.id = generate_session_id()
|
|
91
|
+
self._sessions[record.id] = record
|
|
92
|
+
return record
|
|
93
|
+
|
|
94
|
+
def invalidate(self, session_id: str) -> bool:
|
|
95
|
+
return self._sessions.pop(session_id, None) is not None
|
|
96
|
+
|
|
97
|
+
def purge_expired(self) -> None:
|
|
98
|
+
self._sessions = {
|
|
99
|
+
sid: rec for sid, rec in self._sessions.items() if not rec.expired(self._idle_ttl)
|
|
100
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Framework-native provider for `fastapi-startkit` applications.
|
|
2
|
+
|
|
3
|
+
This module is the only place the package touches the optional framework
|
|
4
|
+
dependency, and it is imported lazily (via the package ``__getattr__``), so
|
|
5
|
+
plain-FastAPI installs never load it. Install the extra to use it::
|
|
6
|
+
|
|
7
|
+
pip install fastapi-startkit-auth[startkit]
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from fastapi_startkit.support import Provider
|
|
15
|
+
except ImportError as exc: # pragma: no cover - exercised via the package import test
|
|
16
|
+
raise ImportError(
|
|
17
|
+
"AuthServiceProvider requires the optional 'fastapi-startkit' framework. "
|
|
18
|
+
"Install it with: pip install fastapi-startkit-auth[startkit]"
|
|
19
|
+
) from exc
|
|
20
|
+
|
|
21
|
+
_PUBLISHABLE = Path(__file__).resolve().parent / "publishable"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthServiceProvider(Provider):
|
|
25
|
+
"""Registers the auth package's publishable assets with the framework.
|
|
26
|
+
|
|
27
|
+
Add it to the application's ``providers=[...]`` list, then publish the
|
|
28
|
+
CORS stub with ``provider:publish -p auth`` (copied to ``config/cors.py``).
|
|
29
|
+
The stub's defaults are also merged under the ``cors`` config key, so the
|
|
30
|
+
published file only overrides them.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
provider_key = "auth"
|
|
34
|
+
|
|
35
|
+
def register(self) -> None:
|
|
36
|
+
cors_stub = str(_PUBLISHABLE / "cors.py")
|
|
37
|
+
self.merge_config_from(cors_stub, "cors")
|
|
38
|
+
self.publishes({cors_stub: "config/cors.py"})
|
|
@@ -0,0 +1,68 @@
|
|
|
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 AccessTokenRecord:
|
|
10
|
+
"""Server-side record of an issued JWT access token.
|
|
11
|
+
|
|
12
|
+
The JWT itself is stateless; this record is what makes revocation and
|
|
13
|
+
introspection possible and also backs personal access tokens.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
jti: str
|
|
17
|
+
user_id: Any | None
|
|
18
|
+
client_id: str | None
|
|
19
|
+
scopes: list[str] = field(default_factory=list)
|
|
20
|
+
expires_at: float | None = None
|
|
21
|
+
revoked: bool = False
|
|
22
|
+
name: str | None = None
|
|
23
|
+
personal_access: bool = False
|
|
24
|
+
created_at: float = field(default_factory=lambda: time.time())
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def expired(self) -> bool:
|
|
28
|
+
return self.expires_at is not None and self.expires_at < time.time()
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def active(self) -> bool:
|
|
32
|
+
return not self.revoked and not self.expired
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RefreshTokenRecord:
|
|
37
|
+
token_id: str
|
|
38
|
+
access_jti: str
|
|
39
|
+
user_id: Any | None
|
|
40
|
+
client_id: str | None
|
|
41
|
+
scopes: list[str] = field(default_factory=list)
|
|
42
|
+
expires_at: float | None = None
|
|
43
|
+
revoked: bool = False
|
|
44
|
+
created_at: float = field(default_factory=lambda: time.time())
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def expired(self) -> bool:
|
|
48
|
+
return self.expires_at is not None and self.expires_at < time.time()
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def active(self) -> bool:
|
|
52
|
+
return not self.revoked and not self.expired
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class AuthorizationCode:
|
|
57
|
+
code: str
|
|
58
|
+
client_id: str
|
|
59
|
+
user_id: Any
|
|
60
|
+
scopes: list[str]
|
|
61
|
+
redirect_uri: str | None
|
|
62
|
+
code_challenge: str | None
|
|
63
|
+
code_challenge_method: str | None
|
|
64
|
+
expires_at: float
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def expired(self) -> bool:
|
|
68
|
+
return self.expires_at < time.time()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .models import AccessTokenRecord, AuthorizationCode, RefreshTokenRecord
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InMemoryTokenRepository:
|
|
10
|
+
"""Default token store.
|
|
11
|
+
|
|
12
|
+
Holds access-token records (for revocation/introspection), refresh tokens,
|
|
13
|
+
and pending authorization codes. Implement the same method surface against a
|
|
14
|
+
real database to persist tokens across processes.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._access: dict[str, AccessTokenRecord] = {}
|
|
19
|
+
self._refresh: dict[str, RefreshTokenRecord] = {}
|
|
20
|
+
self._codes: dict[str, AuthorizationCode] = {}
|
|
21
|
+
|
|
22
|
+
# --- access tokens -------------------------------------------------
|
|
23
|
+
def store_access_token(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
jti: str,
|
|
27
|
+
user_id: Any | None,
|
|
28
|
+
client_id: str | None,
|
|
29
|
+
scopes: list[str],
|
|
30
|
+
expires_at: float | None,
|
|
31
|
+
name: str | None = None,
|
|
32
|
+
personal_access: bool = False,
|
|
33
|
+
) -> AccessTokenRecord:
|
|
34
|
+
rec = AccessTokenRecord(
|
|
35
|
+
jti=jti,
|
|
36
|
+
user_id=user_id,
|
|
37
|
+
client_id=client_id,
|
|
38
|
+
scopes=list(scopes),
|
|
39
|
+
expires_at=expires_at,
|
|
40
|
+
name=name,
|
|
41
|
+
personal_access=personal_access,
|
|
42
|
+
)
|
|
43
|
+
self._access[jti] = rec
|
|
44
|
+
return rec
|
|
45
|
+
|
|
46
|
+
def find_access_token(self, jti: str) -> AccessTokenRecord | None:
|
|
47
|
+
return self._access.get(jti)
|
|
48
|
+
|
|
49
|
+
def revoke_access_token(self, jti: str) -> bool:
|
|
50
|
+
rec = self._access.get(jti)
|
|
51
|
+
if rec is None:
|
|
52
|
+
return False
|
|
53
|
+
rec.revoked = True
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
def list_access_tokens(self, user_id: Any, personal_access: bool | None = None) -> list[AccessTokenRecord]:
|
|
57
|
+
out = [r for r in self._access.values() if r.user_id == user_id]
|
|
58
|
+
if personal_access is not None:
|
|
59
|
+
out = [r for r in out if r.personal_access == personal_access]
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
# --- refresh tokens ------------------------------------------------
|
|
63
|
+
def store_refresh_token(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
token_id: str,
|
|
67
|
+
access_jti: str,
|
|
68
|
+
user_id: Any | None,
|
|
69
|
+
client_id: str | None,
|
|
70
|
+
scopes: list[str],
|
|
71
|
+
expires_at: float | None,
|
|
72
|
+
) -> RefreshTokenRecord:
|
|
73
|
+
rec = RefreshTokenRecord(
|
|
74
|
+
token_id=token_id,
|
|
75
|
+
access_jti=access_jti,
|
|
76
|
+
user_id=user_id,
|
|
77
|
+
client_id=client_id,
|
|
78
|
+
scopes=list(scopes),
|
|
79
|
+
expires_at=expires_at,
|
|
80
|
+
)
|
|
81
|
+
self._refresh[token_id] = rec
|
|
82
|
+
return rec
|
|
83
|
+
|
|
84
|
+
def find_refresh_token(self, token_id: str) -> RefreshTokenRecord | None:
|
|
85
|
+
return self._refresh.get(token_id)
|
|
86
|
+
|
|
87
|
+
def revoke_refresh_token(self, token_id: str) -> bool:
|
|
88
|
+
rec = self._refresh.get(token_id)
|
|
89
|
+
if rec is None:
|
|
90
|
+
return False
|
|
91
|
+
rec.revoked = True
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
# --- authorization codes ------------------------------------------
|
|
95
|
+
def store_auth_code(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
code: str,
|
|
99
|
+
client_id: str,
|
|
100
|
+
user_id: Any,
|
|
101
|
+
scopes: list[str],
|
|
102
|
+
redirect_uri: str | None,
|
|
103
|
+
code_challenge: str | None,
|
|
104
|
+
code_challenge_method: str | None,
|
|
105
|
+
expires_at: float,
|
|
106
|
+
) -> AuthorizationCode:
|
|
107
|
+
rec = AuthorizationCode(
|
|
108
|
+
code=code,
|
|
109
|
+
client_id=client_id,
|
|
110
|
+
user_id=user_id,
|
|
111
|
+
scopes=list(scopes),
|
|
112
|
+
redirect_uri=redirect_uri,
|
|
113
|
+
code_challenge=code_challenge,
|
|
114
|
+
code_challenge_method=code_challenge_method,
|
|
115
|
+
expires_at=expires_at,
|
|
116
|
+
)
|
|
117
|
+
self._codes[code] = rec
|
|
118
|
+
return rec
|
|
119
|
+
|
|
120
|
+
def pull_auth_code(self, code: str) -> AuthorizationCode | None:
|
|
121
|
+
"""Return and consume an authorization code (single use)."""
|
|
122
|
+
return self._codes.pop(code, None)
|
|
123
|
+
|
|
124
|
+
# --- maintenance ---------------------------------------------------
|
|
125
|
+
def purge_expired(self) -> None:
|
|
126
|
+
now = time.time()
|
|
127
|
+
self._access = {k: v for k, v in self._access.items() if not (v.expires_at and v.expires_at < now)}
|
|
128
|
+
self._refresh = {k: v for k, v in self._refresh.items() if not (v.expires_at and v.expires_at < now)}
|
|
129
|
+
self._codes = {k: v for k, v in self._codes.items() if v.expires_at >= now}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..exceptions import InvalidGrant, InvalidToken
|
|
9
|
+
from ..security.jwt import JWTEncoder
|
|
10
|
+
from .repository import InMemoryTokenRepository
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class IssuedToken:
|
|
15
|
+
"""The result of minting a token, serialisable to an OAuth2 token response."""
|
|
16
|
+
|
|
17
|
+
access_token: str
|
|
18
|
+
token_type: str
|
|
19
|
+
expires_in: int
|
|
20
|
+
scopes: list[str]
|
|
21
|
+
jti: str
|
|
22
|
+
refresh_token: str | None = None
|
|
23
|
+
|
|
24
|
+
def to_response(self) -> dict[str, Any]:
|
|
25
|
+
body: dict[str, Any] = {
|
|
26
|
+
"access_token": self.access_token,
|
|
27
|
+
"token_type": self.token_type,
|
|
28
|
+
"expires_in": self.expires_in,
|
|
29
|
+
"scope": " ".join(self.scopes),
|
|
30
|
+
}
|
|
31
|
+
if self.refresh_token is not None:
|
|
32
|
+
body["refresh_token"] = self.refresh_token
|
|
33
|
+
return body
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TokenService:
|
|
37
|
+
"""Mints, rotates, revokes, and introspects tokens.
|
|
38
|
+
|
|
39
|
+
Access tokens are signed JWTs; a server-side record per ``jti`` enables
|
|
40
|
+
revocation and introspection. Refresh tokens are opaque high-entropy strings
|
|
41
|
+
mapped to stored records, and are rotated (old access + refresh revoked) on
|
|
42
|
+
every use.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
encoder: JWTEncoder,
|
|
48
|
+
repository: InMemoryTokenRepository | None = None,
|
|
49
|
+
access_ttl: int = 3600,
|
|
50
|
+
refresh_ttl: int = 1209600,
|
|
51
|
+
personal_access_ttl: int = 31536000,
|
|
52
|
+
purge_interval: int = 300,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.encoder = encoder
|
|
55
|
+
self.repository = repository or InMemoryTokenRepository()
|
|
56
|
+
self.access_ttl = access_ttl
|
|
57
|
+
self.refresh_ttl = refresh_ttl
|
|
58
|
+
self.personal_access_ttl = personal_access_ttl
|
|
59
|
+
self._purge_interval = purge_interval
|
|
60
|
+
self._last_purge = time.time()
|
|
61
|
+
|
|
62
|
+
def _maybe_purge(self) -> None:
|
|
63
|
+
"""Drop expired records periodically so in-memory stores stay bounded."""
|
|
64
|
+
now = time.time()
|
|
65
|
+
if now - self._last_purge >= self._purge_interval:
|
|
66
|
+
self._last_purge = now
|
|
67
|
+
self.repository.purge_expired()
|
|
68
|
+
|
|
69
|
+
def issue(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
user_id: Any | None,
|
|
73
|
+
client_id: str | None,
|
|
74
|
+
scopes: list[str],
|
|
75
|
+
with_refresh: bool = False,
|
|
76
|
+
ttl: int | None = None,
|
|
77
|
+
name: str | None = None,
|
|
78
|
+
personal_access: bool = False,
|
|
79
|
+
) -> IssuedToken:
|
|
80
|
+
self._maybe_purge()
|
|
81
|
+
ttl = self.access_ttl if ttl is None else ttl
|
|
82
|
+
sub = None if user_id is None else str(user_id)
|
|
83
|
+
access_token = self.encoder.encode(
|
|
84
|
+
{
|
|
85
|
+
"sub": sub,
|
|
86
|
+
"client_id": client_id,
|
|
87
|
+
"scopes": list(scopes),
|
|
88
|
+
"token_type": "access",
|
|
89
|
+
},
|
|
90
|
+
ttl_seconds=ttl,
|
|
91
|
+
)
|
|
92
|
+
jti = self.encoder.decode(access_token, verify_exp=False)["jti"]
|
|
93
|
+
self.repository.store_access_token(
|
|
94
|
+
jti=jti,
|
|
95
|
+
user_id=user_id,
|
|
96
|
+
client_id=client_id,
|
|
97
|
+
scopes=list(scopes),
|
|
98
|
+
expires_at=time.time() + ttl,
|
|
99
|
+
name=name,
|
|
100
|
+
personal_access=personal_access,
|
|
101
|
+
)
|
|
102
|
+
refresh_token = None
|
|
103
|
+
if with_refresh:
|
|
104
|
+
refresh_token = self._issue_refresh(jti, user_id, client_id, scopes)
|
|
105
|
+
return IssuedToken(
|
|
106
|
+
access_token=access_token,
|
|
107
|
+
token_type="Bearer",
|
|
108
|
+
expires_in=ttl,
|
|
109
|
+
scopes=list(scopes),
|
|
110
|
+
jti=jti,
|
|
111
|
+
refresh_token=refresh_token,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _issue_refresh(self, access_jti: str, user_id, client_id, scopes) -> str:
|
|
115
|
+
token_id = secrets.token_urlsafe(48)
|
|
116
|
+
self.repository.store_refresh_token(
|
|
117
|
+
token_id=token_id,
|
|
118
|
+
access_jti=access_jti,
|
|
119
|
+
user_id=user_id,
|
|
120
|
+
client_id=client_id,
|
|
121
|
+
scopes=list(scopes),
|
|
122
|
+
expires_at=time.time() + self.refresh_ttl,
|
|
123
|
+
)
|
|
124
|
+
return token_id
|
|
125
|
+
|
|
126
|
+
def create_personal_access_token(
|
|
127
|
+
self, *, user_id: Any, name: str, scopes: list[str], ttl: int | None = None
|
|
128
|
+
) -> IssuedToken:
|
|
129
|
+
return self.issue(
|
|
130
|
+
user_id=user_id,
|
|
131
|
+
client_id=None,
|
|
132
|
+
scopes=scopes,
|
|
133
|
+
ttl=self.personal_access_ttl if ttl is None else ttl,
|
|
134
|
+
name=name,
|
|
135
|
+
personal_access=True,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def refresh(self, refresh_token: str, scopes: list[str] | None = None) -> IssuedToken:
|
|
139
|
+
record = self.repository.find_refresh_token(refresh_token)
|
|
140
|
+
if record is None or not record.active:
|
|
141
|
+
raise InvalidGrant("The refresh token is invalid, expired, or revoked.")
|
|
142
|
+
|
|
143
|
+
new_scopes = list(record.scopes)
|
|
144
|
+
if scopes is not None:
|
|
145
|
+
widened = set(scopes) - set(record.scopes)
|
|
146
|
+
if widened:
|
|
147
|
+
raise InvalidGrant("Requested scopes exceed those of the original grant.")
|
|
148
|
+
new_scopes = list(scopes)
|
|
149
|
+
|
|
150
|
+
# Rotate: revoke the previous access + refresh pair before minting a new one.
|
|
151
|
+
self.repository.revoke_access_token(record.access_jti)
|
|
152
|
+
self.repository.revoke_refresh_token(record.token_id)
|
|
153
|
+
|
|
154
|
+
return self.issue(
|
|
155
|
+
user_id=record.user_id,
|
|
156
|
+
client_id=record.client_id,
|
|
157
|
+
scopes=new_scopes,
|
|
158
|
+
with_refresh=True,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def authenticate(self, access_token: str) -> dict[str, Any]:
|
|
162
|
+
claims = self.encoder.decode(access_token)
|
|
163
|
+
record = self.repository.find_access_token(claims.get("jti", ""))
|
|
164
|
+
if record is None or not record.active:
|
|
165
|
+
raise InvalidToken("The access token has been revoked or is unknown.")
|
|
166
|
+
return claims
|
|
167
|
+
|
|
168
|
+
def revoke_access(self, jti: str) -> bool:
|
|
169
|
+
return self.repository.revoke_access_token(jti)
|
|
170
|
+
|
|
171
|
+
def revoke_refresh(self, token_id: str) -> bool:
|
|
172
|
+
return self.repository.revoke_refresh_token(token_id)
|
|
173
|
+
|
|
174
|
+
def introspect(self, access_token: str) -> dict[str, Any]:
|
|
175
|
+
try:
|
|
176
|
+
claims = self.authenticate(access_token)
|
|
177
|
+
except InvalidToken:
|
|
178
|
+
return {"active": False}
|
|
179
|
+
record = self.repository.find_access_token(claims["jti"])
|
|
180
|
+
return {
|
|
181
|
+
"active": True,
|
|
182
|
+
"scope": " ".join(record.scopes),
|
|
183
|
+
"client_id": record.client_id,
|
|
184
|
+
"sub": claims.get("sub"),
|
|
185
|
+
"token_type": "Bearer",
|
|
186
|
+
"exp": claims.get("exp"),
|
|
187
|
+
"iat": claims.get("iat"),
|
|
188
|
+
"jti": claims["jti"],
|
|
189
|
+
}
|