lorebase-keeper 0.2.3__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.
- lorebase_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Browser sessions: Keeper as backend-for-frontend of the web app (design 13.2).
|
|
2
|
+
|
|
3
|
+
Sign-in runs the authorization code flow with PKCE. `begin_login` returns the provider URL and
|
|
4
|
+
an encrypted, short-lived cookie holding state, nonce, PKCE verifier and return path;
|
|
5
|
+
`finish_login` checks the callback against it (state, then the ID token's signature, issuer,
|
|
6
|
+
audience and nonce) and `create` stores a session. The browser only ever holds an opaque,
|
|
7
|
+
random session id in an `HttpOnly` cookie; the database holds its SHA-256, the user's
|
|
8
|
+
principal and provider groups, and the provider's refresh and ID tokens encrypted with a key
|
|
9
|
+
derived from `sessions.encryption_key`.
|
|
10
|
+
|
|
11
|
+
Each request with the cookie is resolved by `resolve`: unknown, idle or expired sessions are
|
|
12
|
+
refused (and deleted); when the provider's access token has expired, one request uses the
|
|
13
|
+
refresh token, and a refusal ends the session. Unsafe methods must pass `check_request`: a
|
|
14
|
+
CSRF token (an HMAC of the session id, given to the app by `GET /auth/session`) in a header,
|
|
15
|
+
an acceptable `Origin` and no cross-site `Sec-Fetch-Site`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import binascii
|
|
22
|
+
import hashlib
|
|
23
|
+
import hmac
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Callable, Mapping
|
|
28
|
+
from dataclasses import dataclass, replace
|
|
29
|
+
from datetime import UTC, datetime, timedelta
|
|
30
|
+
from typing import Literal
|
|
31
|
+
|
|
32
|
+
from starlette.requests import cookie_parser
|
|
33
|
+
|
|
34
|
+
from lorebase_keeper.auth.config import SessionSettings, is_local_path
|
|
35
|
+
from lorebase_keeper.auth.keyring import Keyring
|
|
36
|
+
from lorebase_keeper.auth.oidc import (
|
|
37
|
+
InvalidTokenError,
|
|
38
|
+
OidcClaims,
|
|
39
|
+
OidcVerifier,
|
|
40
|
+
ProviderUnavailableError,
|
|
41
|
+
)
|
|
42
|
+
from lorebase_keeper.auth.ports import AuthStore
|
|
43
|
+
from lorebase_keeper.auth.provider import (
|
|
44
|
+
ProviderClient,
|
|
45
|
+
TokenRefusedError,
|
|
46
|
+
TokenResponse,
|
|
47
|
+
pkce_pair,
|
|
48
|
+
random_token,
|
|
49
|
+
)
|
|
50
|
+
from lorebase_keeper.errors import FORBIDDEN, ProblemError
|
|
51
|
+
from lorebase_store.auth import SessionRecord
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger("lorebase.keeper.auth.sessions")
|
|
54
|
+
|
|
55
|
+
LoginErrorCode = Literal[
|
|
56
|
+
"login_expired", "login_failed", "access_denied", "no_account", "provider_unavailable"
|
|
57
|
+
]
|
|
58
|
+
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
59
|
+
_ALLOWED_FETCH_SITES = frozenset({"same-origin", "same-site", "none"})
|
|
60
|
+
_SESSION_ID = re.compile(r"^[A-Za-z0-9_-]{32,128}$")
|
|
61
|
+
_LOGIN_PURPOSE = "login-state"
|
|
62
|
+
_TOKENS_PURPOSE = "session-tokens"
|
|
63
|
+
_CSRF_PURPOSE = "csrf"
|
|
64
|
+
_MAX_PARAM_LENGTH = 4096
|
|
65
|
+
|
|
66
|
+
LOGIN_PATH = "/auth/login"
|
|
67
|
+
CALLBACK_PATH = "/auth/callback"
|
|
68
|
+
LOGOUT_PATH = "/auth/logout"
|
|
69
|
+
PUBLIC_PATHS = frozenset({LOGIN_PATH, CALLBACK_PATH, LOGOUT_PATH})
|
|
70
|
+
"""Routes (without the API prefix) that work without a valid credential: the limits dependency
|
|
71
|
+
does not authenticate them, so a stale cookie never blocks signing in or out."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class LoginError(Exception):
|
|
75
|
+
"""A sign-in that cannot complete. `code` is shown to the user; the message is for logs."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, code: LoginErrorCode, message: str) -> None:
|
|
78
|
+
super().__init__(message)
|
|
79
|
+
self.code: LoginErrorCode = code
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class LoginStart:
|
|
84
|
+
"""Where to send the browser, and the cookie that must come back with the callback."""
|
|
85
|
+
|
|
86
|
+
url: str
|
|
87
|
+
state_cookie: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class LoginResult:
|
|
92
|
+
"""A verified sign-in: the user's claims, the provider's tokens, the return path and the
|
|
93
|
+
nonce the ID token was bound to (refreshed ID tokens must repeat it if they carry one)."""
|
|
94
|
+
|
|
95
|
+
claims: OidcClaims
|
|
96
|
+
tokens: TokenResponse
|
|
97
|
+
return_to: str
|
|
98
|
+
nonce: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class NewSession:
|
|
103
|
+
"""A session just created: the id goes into the cookie, never into storage."""
|
|
104
|
+
|
|
105
|
+
session_id: str
|
|
106
|
+
record: SessionRecord
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class SignOut:
|
|
111
|
+
"""An ended session: whose it was and the provider's logout URL (None when unknown)."""
|
|
112
|
+
|
|
113
|
+
principal_id: str | None
|
|
114
|
+
logout_url: str | None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def digest(session_id: str) -> bytes:
|
|
118
|
+
"""The stored form of a session id."""
|
|
119
|
+
return hashlib.sha256(session_id.encode("ascii")).digest()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _b64encode(raw: bytes) -> str:
|
|
123
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _b64decode(text: str) -> bytes | None:
|
|
127
|
+
try:
|
|
128
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
129
|
+
except (binascii.Error, ValueError):
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cookie_value(headers: Mapping[str, str], name: str) -> str | None:
|
|
134
|
+
"""The value of cookie `name` in the request headers (lower-case names)."""
|
|
135
|
+
header = headers.get("cookie")
|
|
136
|
+
if not header:
|
|
137
|
+
return None
|
|
138
|
+
return cookie_parser(header).get(name) or None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class SessionManager:
|
|
142
|
+
"""Sign-in, session lookup and refresh, CSRF checks and sign-out."""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
settings: SessionSettings,
|
|
147
|
+
*,
|
|
148
|
+
store: AuthStore,
|
|
149
|
+
provider: ProviderClient,
|
|
150
|
+
verifier: OidcVerifier,
|
|
151
|
+
clock: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
152
|
+
) -> None:
|
|
153
|
+
key = settings.encryption_key
|
|
154
|
+
if key is None:
|
|
155
|
+
raise ValueError("sessions need an encryption key")
|
|
156
|
+
self.settings = settings
|
|
157
|
+
self._store = store
|
|
158
|
+
self._provider = provider
|
|
159
|
+
self._verifier = verifier
|
|
160
|
+
self._keys = Keyring(key.get_secret_value())
|
|
161
|
+
self._clock = clock
|
|
162
|
+
self._origins = settings.trusted_origins()
|
|
163
|
+
|
|
164
|
+
# Sign-in -------------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
async def begin_login(self, return_to: str) -> LoginStart:
|
|
167
|
+
"""Start a sign-in that returns to `return_to` (a local path, checked by the caller).
|
|
168
|
+
|
|
169
|
+
`ProviderUnavailableError` when the provider metadata cannot be read.
|
|
170
|
+
"""
|
|
171
|
+
metadata = await self._provider.metadata()
|
|
172
|
+
state, nonce = random_token(), random_token()
|
|
173
|
+
verifier, challenge = pkce_pair()
|
|
174
|
+
expires = self._clock() + timedelta(seconds=self.settings.login_timeout_s)
|
|
175
|
+
payload = json.dumps(
|
|
176
|
+
{"s": state, "n": nonce, "v": verifier, "r": return_to, "e": expires.timestamp()},
|
|
177
|
+
separators=(",", ":"),
|
|
178
|
+
).encode()
|
|
179
|
+
return LoginStart(
|
|
180
|
+
url=self._provider.authorization_url(
|
|
181
|
+
metadata, state=state, nonce=nonce, challenge=challenge
|
|
182
|
+
),
|
|
183
|
+
state_cookie=_b64encode(self._keys.seal(_LOGIN_PURPOSE, payload)),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
async def finish_login(
|
|
187
|
+
self, params: Mapping[str, str], state_cookie: str | None
|
|
188
|
+
) -> LoginResult:
|
|
189
|
+
"""Verify a callback (`params` is its query) against the sign-in state cookie and redeem
|
|
190
|
+
the code. `LoginError` names what went wrong."""
|
|
191
|
+
pending = self._open_login_state(state_cookie)
|
|
192
|
+
if pending is None:
|
|
193
|
+
raise LoginError("login_expired", "sign-in state cookie missing, altered or expired")
|
|
194
|
+
error = params.get("error")
|
|
195
|
+
if error is not None:
|
|
196
|
+
code: LoginErrorCode = "access_denied" if error == "access_denied" else "login_failed"
|
|
197
|
+
raise LoginError(code, f"provider returned error {error[:64]!r}")
|
|
198
|
+
state, code_param = params.get("state", ""), params.get("code", "")
|
|
199
|
+
if not state or not hmac.compare_digest(state.encode(), pending["s"].encode()):
|
|
200
|
+
raise LoginError("login_failed", "state does not match the sign-in state cookie")
|
|
201
|
+
if not code_param or len(code_param) > _MAX_PARAM_LENGTH:
|
|
202
|
+
raise LoginError("login_failed", "callback without a usable code")
|
|
203
|
+
try:
|
|
204
|
+
tokens = await self._provider.exchange_code(code_param, pending["v"])
|
|
205
|
+
except TokenRefusedError as exc:
|
|
206
|
+
raise LoginError("login_failed", str(exc)) from None
|
|
207
|
+
except ProviderUnavailableError as exc:
|
|
208
|
+
raise LoginError("provider_unavailable", str(exc)) from None
|
|
209
|
+
if tokens.id_token is None:
|
|
210
|
+
raise LoginError("login_failed", "token response without an id_token")
|
|
211
|
+
try:
|
|
212
|
+
claims = await self._verifier.verify_id_token(
|
|
213
|
+
tokens.id_token, client_id=self._provider.client_id, nonce=pending["n"]
|
|
214
|
+
)
|
|
215
|
+
except InvalidTokenError as exc:
|
|
216
|
+
raise LoginError("login_failed", f"id_token rejected: {exc}") from None
|
|
217
|
+
except ProviderUnavailableError as exc:
|
|
218
|
+
raise LoginError("provider_unavailable", str(exc)) from None
|
|
219
|
+
return LoginResult(claims=claims, tokens=tokens, return_to=pending["r"], nonce=pending["n"])
|
|
220
|
+
|
|
221
|
+
def _open_login_state(self, cookie: str | None) -> dict[str, str] | None:
|
|
222
|
+
if not cookie or len(cookie) > _MAX_PARAM_LENGTH:
|
|
223
|
+
return None
|
|
224
|
+
sealed = _b64decode(cookie)
|
|
225
|
+
plain = self._keys.open(_LOGIN_PURPOSE, sealed) if sealed else None
|
|
226
|
+
if plain is None:
|
|
227
|
+
return None
|
|
228
|
+
try:
|
|
229
|
+
data = json.loads(plain)
|
|
230
|
+
except ValueError:
|
|
231
|
+
return None
|
|
232
|
+
fields = ("s", "n", "v", "r")
|
|
233
|
+
if not isinstance(data, dict) or not all(isinstance(data.get(f), str) for f in fields):
|
|
234
|
+
return None
|
|
235
|
+
expires = data.get("e")
|
|
236
|
+
if not isinstance(expires, int | float) or expires <= self._clock().timestamp():
|
|
237
|
+
return None
|
|
238
|
+
if not is_local_path(data["r"]):
|
|
239
|
+
return None
|
|
240
|
+
return {name: data[name] for name in fields}
|
|
241
|
+
|
|
242
|
+
# Sessions ------------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
async def create(
|
|
245
|
+
self, principal_id: str, login: LoginResult, *, replaces: str | None = None
|
|
246
|
+
) -> NewSession:
|
|
247
|
+
"""Store a new session for a verified sign-in, ending the session it `replaces` (the
|
|
248
|
+
cookie the browser sent, if any: a new id on every sign-in prevents fixation)."""
|
|
249
|
+
settings = self.settings
|
|
250
|
+
now = self._clock()
|
|
251
|
+
session_id = random_token()
|
|
252
|
+
refresh_token = login.tokens.refresh_token if settings.refresh_tokens else None
|
|
253
|
+
sealed = self._seal_tokens(refresh_token, login.tokens.id_token, login.nonce)
|
|
254
|
+
async with self._store.unit() as unit:
|
|
255
|
+
if replaces and _SESSION_ID.match(replaces):
|
|
256
|
+
await unit.sessions.delete(digest(replaces))
|
|
257
|
+
if settings.purge_batch:
|
|
258
|
+
await unit.sessions.purge_expired(
|
|
259
|
+
now,
|
|
260
|
+
now - timedelta(seconds=settings.idle_timeout_s),
|
|
261
|
+
limit=settings.purge_batch,
|
|
262
|
+
)
|
|
263
|
+
record = await unit.sessions.add(
|
|
264
|
+
id_hash=digest(session_id),
|
|
265
|
+
principal_id=principal_id,
|
|
266
|
+
subject=login.claims.subject,
|
|
267
|
+
external_groups=login.claims.groups,
|
|
268
|
+
tokens=sealed,
|
|
269
|
+
created_at=now,
|
|
270
|
+
expires_at=now + timedelta(seconds=settings.absolute_timeout_s),
|
|
271
|
+
refresh_at=self._next_refresh(now, login.tokens) if refresh_token else None,
|
|
272
|
+
)
|
|
273
|
+
return NewSession(session_id=session_id, record=record)
|
|
274
|
+
|
|
275
|
+
async def resolve(self, session_id: str) -> SessionRecord | None:
|
|
276
|
+
"""The live session with this id, refreshed at the provider when due; None when it is
|
|
277
|
+
unknown, idle too long, past its absolute lifetime or refused by the provider."""
|
|
278
|
+
if not _SESSION_ID.match(session_id):
|
|
279
|
+
return None
|
|
280
|
+
settings = self.settings
|
|
281
|
+
key = digest(session_id)
|
|
282
|
+
now = self._clock()
|
|
283
|
+
async with self._store.unit() as unit:
|
|
284
|
+
record = await unit.sessions.get(key)
|
|
285
|
+
if record is None:
|
|
286
|
+
return None
|
|
287
|
+
if self._expired(record, now):
|
|
288
|
+
await unit.sessions.delete(key)
|
|
289
|
+
return None
|
|
290
|
+
touch = timedelta(seconds=settings.touch_interval_s)
|
|
291
|
+
if now - record.last_seen_at >= touch:
|
|
292
|
+
await unit.sessions.touch(key, now, min_interval=touch)
|
|
293
|
+
record = replace(record, last_seen_at=now)
|
|
294
|
+
if settings.refresh_tokens and record.refresh_at is not None and record.refresh_at <= now:
|
|
295
|
+
return await self._refresh(record, now)
|
|
296
|
+
return record
|
|
297
|
+
|
|
298
|
+
def _expired(self, record: SessionRecord, now: datetime) -> bool:
|
|
299
|
+
idle = timedelta(seconds=self.settings.idle_timeout_s)
|
|
300
|
+
return record.expires_at <= now or record.last_seen_at + idle <= now
|
|
301
|
+
|
|
302
|
+
async def _refresh(self, record: SessionRecord, now: datetime) -> SessionRecord | None:
|
|
303
|
+
"""Refresh the provider tokens, if this request wins the lease; others go on with the
|
|
304
|
+
session as it is."""
|
|
305
|
+
settings = self.settings
|
|
306
|
+
key = record.id_hash
|
|
307
|
+
lease = now + timedelta(seconds=settings.refresh_lease_s)
|
|
308
|
+
async with self._store.unit() as unit:
|
|
309
|
+
if not await unit.sessions.claim_refresh(key, now, lease):
|
|
310
|
+
return record
|
|
311
|
+
tokens = self._open_tokens(record.tokens) or {}
|
|
312
|
+
refresh_token = tokens.get("refresh_token")
|
|
313
|
+
if refresh_token is None:
|
|
314
|
+
# Sealed under another key (it changed) or nothing to refresh with: no provider
|
|
315
|
+
# check is possible any more, so the session ends.
|
|
316
|
+
logger.info(
|
|
317
|
+
"session ended: no usable refresh token", extra={"principal": record.principal_id}
|
|
318
|
+
)
|
|
319
|
+
await self._delete(key)
|
|
320
|
+
return None
|
|
321
|
+
try:
|
|
322
|
+
response = await self._provider.refresh(refresh_token)
|
|
323
|
+
claims = (
|
|
324
|
+
await self._verifier.verify_id_token(
|
|
325
|
+
response.id_token,
|
|
326
|
+
client_id=self._provider.client_id,
|
|
327
|
+
nonce=tokens.get("nonce"),
|
|
328
|
+
nonce_required=False,
|
|
329
|
+
)
|
|
330
|
+
if response.id_token
|
|
331
|
+
else None
|
|
332
|
+
)
|
|
333
|
+
except (TokenRefusedError, InvalidTokenError) as exc:
|
|
334
|
+
logger.info(
|
|
335
|
+
"session ended: refresh refused (%s)", exc, extra={"principal": record.principal_id}
|
|
336
|
+
)
|
|
337
|
+
await self._delete(key)
|
|
338
|
+
return None
|
|
339
|
+
except ProviderUnavailableError:
|
|
340
|
+
logger.warning(
|
|
341
|
+
"session refresh postponed: provider unavailable",
|
|
342
|
+
extra={"principal": record.principal_id},
|
|
343
|
+
)
|
|
344
|
+
return record
|
|
345
|
+
if claims is not None and claims.subject != record.subject:
|
|
346
|
+
logger.warning(
|
|
347
|
+
"session ended: refreshed id_token names another subject",
|
|
348
|
+
extra={"principal": record.principal_id},
|
|
349
|
+
)
|
|
350
|
+
await self._delete(key)
|
|
351
|
+
return None
|
|
352
|
+
groups = claims.groups if claims is not None else record.external_groups
|
|
353
|
+
sealed = self._seal_tokens(
|
|
354
|
+
response.refresh_token or refresh_token,
|
|
355
|
+
response.id_token or tokens.get("id_token"),
|
|
356
|
+
tokens.get("nonce"),
|
|
357
|
+
)
|
|
358
|
+
refresh_at = self._next_refresh(now, response)
|
|
359
|
+
async with self._store.unit() as unit:
|
|
360
|
+
stored = await unit.sessions.update_tokens(
|
|
361
|
+
key, tokens=sealed, external_groups=groups, refresh_at=refresh_at
|
|
362
|
+
)
|
|
363
|
+
if not stored:
|
|
364
|
+
return None
|
|
365
|
+
return replace(record, tokens=sealed, external_groups=tuple(groups), refresh_at=refresh_at)
|
|
366
|
+
|
|
367
|
+
def _next_refresh(self, now: datetime, tokens: TokenResponse) -> datetime:
|
|
368
|
+
settings = self.settings
|
|
369
|
+
interval = max(
|
|
370
|
+
settings.refresh_min_interval_s,
|
|
371
|
+
tokens.expires_in or settings.refresh_default_interval_s,
|
|
372
|
+
)
|
|
373
|
+
return now + timedelta(seconds=interval)
|
|
374
|
+
|
|
375
|
+
def _seal_tokens(
|
|
376
|
+
self, refresh_token: str | None, id_token: str | None, nonce: str | None
|
|
377
|
+
) -> bytes | None:
|
|
378
|
+
pairs = (("refresh_token", refresh_token), ("id_token", id_token), ("nonce", nonce))
|
|
379
|
+
values = {name: value for name, value in pairs if value}
|
|
380
|
+
if not values:
|
|
381
|
+
return None
|
|
382
|
+
return self._keys.seal(_TOKENS_PURPOSE, json.dumps(values).encode())
|
|
383
|
+
|
|
384
|
+
def _open_tokens(self, sealed: bytes | None) -> dict[str, str] | None:
|
|
385
|
+
plain = self._keys.open(_TOKENS_PURPOSE, sealed) if sealed else None
|
|
386
|
+
if plain is None:
|
|
387
|
+
return None
|
|
388
|
+
try:
|
|
389
|
+
data = json.loads(plain)
|
|
390
|
+
except ValueError:
|
|
391
|
+
return None
|
|
392
|
+
if not isinstance(data, dict):
|
|
393
|
+
return None
|
|
394
|
+
return {k: v for k, v in data.items() if isinstance(k, str) and isinstance(v, str)}
|
|
395
|
+
|
|
396
|
+
async def _delete(self, key: bytes) -> None:
|
|
397
|
+
async with self._store.unit() as unit:
|
|
398
|
+
await unit.sessions.delete(key)
|
|
399
|
+
|
|
400
|
+
async def end(self, session_id: str) -> SignOut:
|
|
401
|
+
"""Sign out: delete the session and give the provider's logout URL (with the ID token
|
|
402
|
+
as hint); nothing when the session is unknown."""
|
|
403
|
+
if not _SESSION_ID.match(session_id):
|
|
404
|
+
return SignOut(None, None)
|
|
405
|
+
key = digest(session_id)
|
|
406
|
+
async with self._store.unit() as unit:
|
|
407
|
+
record = await unit.sessions.get(key)
|
|
408
|
+
if record is None:
|
|
409
|
+
return SignOut(None, None)
|
|
410
|
+
await unit.sessions.delete(key)
|
|
411
|
+
tokens = self._open_tokens(record.tokens) or {}
|
|
412
|
+
try:
|
|
413
|
+
metadata = await self._provider.metadata()
|
|
414
|
+
except ProviderUnavailableError:
|
|
415
|
+
return SignOut(record.principal_id, None)
|
|
416
|
+
url = self._provider.end_session_url(metadata, tokens.get("id_token"))
|
|
417
|
+
return SignOut(record.principal_id, url)
|
|
418
|
+
|
|
419
|
+
# CSRF ----------------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
def csrf_token(self, session_id: str) -> str:
|
|
422
|
+
"""The CSRF token of a session: an HMAC of its id, so nothing more is stored."""
|
|
423
|
+
return _b64encode(self._keys.mac(_CSRF_PURPOSE, session_id.encode("ascii")))
|
|
424
|
+
|
|
425
|
+
def check_request(self, session_id: str, method: str, headers: Mapping[str, str]) -> None:
|
|
426
|
+
"""Refuse (403, `reason: csrf`) an unsafe request authenticated by the session cookie
|
|
427
|
+
unless it carries the session's CSRF token, comes from a trusted origin when it names
|
|
428
|
+
one, and is not marked cross-site by the browser."""
|
|
429
|
+
if method.upper() in SAFE_METHODS:
|
|
430
|
+
return
|
|
431
|
+
if not _SESSION_ID.match(session_id):
|
|
432
|
+
raise _csrf_refusal("malformed session id")
|
|
433
|
+
site = headers.get("sec-fetch-site")
|
|
434
|
+
if site is not None and site.lower() not in _ALLOWED_FETCH_SITES:
|
|
435
|
+
raise _csrf_refusal("cross-site request")
|
|
436
|
+
origin = headers.get("origin")
|
|
437
|
+
if origin is not None and origin.lower() not in self._origins:
|
|
438
|
+
raise _csrf_refusal("untrusted origin")
|
|
439
|
+
token = headers.get(self.settings.csrf_header.lower())
|
|
440
|
+
expected = self.csrf_token(session_id)
|
|
441
|
+
if not token or not hmac.compare_digest(token.encode(), expected.encode()):
|
|
442
|
+
raise _csrf_refusal("missing or wrong token")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _csrf_refusal(reason: str) -> ProblemError:
|
|
446
|
+
logger.info("csrf check failed: %s", reason)
|
|
447
|
+
return ProblemError(
|
|
448
|
+
FORBIDDEN,
|
|
449
|
+
"The request was refused by the CSRF check of its session: send the token of "
|
|
450
|
+
"GET /auth/session in the configured header from the web app's origin.",
|
|
451
|
+
extensions={"reason": "csrf"},
|
|
452
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""First installation administrator and service principals, written with the database settings.
|
|
2
|
+
|
|
3
|
+
In `api_key` mode no request is accepted without a key, and only an administrator can issue
|
|
4
|
+
one, so the first key comes from the database side: `python -m lorebase_keeper bootstrap`
|
|
5
|
+
(design 13.2). It reads the same settings as Keeper (database, `keeper.auth`, the pepper).
|
|
6
|
+
|
|
7
|
+
- `bootstrap` creates the service principal named in `keeper.auth.installation_admins` (the
|
|
8
|
+
first entry unless `--principal` names another one) and a key for it. It refuses when an
|
|
9
|
+
installation administrator already holds a usable key, unless `force` is given; then one more
|
|
10
|
+
key is added and the existing ones stay valid. The new token is returned once and never stored.
|
|
11
|
+
- `create_service_principal` adds a service principal with a chosen id, so that a schema can
|
|
12
|
+
name it in Operation permissions before any key exists. The API creates service principals
|
|
13
|
+
only with generated ids.
|
|
14
|
+
|
|
15
|
+
Both write an audit entry on the installation-wide chain.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import datetime, timedelta
|
|
22
|
+
|
|
23
|
+
from lorebase_keeper.auth.api_keys import ApiKeyCodec, issue_api_key
|
|
24
|
+
from lorebase_keeper.auth.config import ApiKeySettings, AuthSettings
|
|
25
|
+
from lorebase_keeper.auth.ports import AuthStore
|
|
26
|
+
from lorebase_store.auth import NewAuditEntry
|
|
27
|
+
from lorebase_store.models.enums import PrincipalType
|
|
28
|
+
|
|
29
|
+
BOOTSTRAP_ACTOR = "system:bootstrap"
|
|
30
|
+
BOOTSTRAP_ACTION = "apikey.bootstrap"
|
|
31
|
+
PRINCIPAL_ACTION = "principal.create"
|
|
32
|
+
DEFAULT_ADMIN_NAME = "installation administrator"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BootstrapError(Exception):
|
|
36
|
+
"""The request cannot be carried out; the message says why and what to change."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class BootstrapKey:
|
|
41
|
+
"""The key created by `bootstrap`. `token` exists only here."""
|
|
42
|
+
|
|
43
|
+
principal_id: str
|
|
44
|
+
principal_created: bool
|
|
45
|
+
key_id: str
|
|
46
|
+
prefix: str
|
|
47
|
+
token: str
|
|
48
|
+
expires_at: datetime | None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def bootstrap_expiry(settings: ApiKeySettings, now: datetime) -> datetime | None:
|
|
52
|
+
"""Expiry of a bootstrap key: the default lifetime of new keys (within `max_ttl_days`)."""
|
|
53
|
+
days = settings.default_ttl_days
|
|
54
|
+
return None if days is None else now + timedelta(days=days)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def bootstrap(
|
|
58
|
+
store: AuthStore,
|
|
59
|
+
settings: AuthSettings,
|
|
60
|
+
*,
|
|
61
|
+
now: datetime,
|
|
62
|
+
principal_id: str | None = None,
|
|
63
|
+
display_name: str = DEFAULT_ADMIN_NAME,
|
|
64
|
+
force: bool = False,
|
|
65
|
+
) -> BootstrapKey:
|
|
66
|
+
"""Create the first installation administrator key (see the module documentation)."""
|
|
67
|
+
if not settings.uses_api_keys:
|
|
68
|
+
raise BootstrapError(
|
|
69
|
+
f"keeper.auth.mode is {settings.mode!r}: API keys are not accepted, so there is no "
|
|
70
|
+
"key to bootstrap (use api_key or api_key+oidc)"
|
|
71
|
+
)
|
|
72
|
+
admins = settings.installation_admins
|
|
73
|
+
if not admins:
|
|
74
|
+
raise BootstrapError(
|
|
75
|
+
"keeper.auth.installation_admins is empty: name the administrator principal id there"
|
|
76
|
+
)
|
|
77
|
+
target = principal_id or admins[0]
|
|
78
|
+
if target not in admins:
|
|
79
|
+
raise BootstrapError(
|
|
80
|
+
f"{target!r} is not listed in keeper.auth.installation_admins ({', '.join(admins)})"
|
|
81
|
+
)
|
|
82
|
+
codec = ApiKeyCodec(settings.api_keys)
|
|
83
|
+
expires_at = bootstrap_expiry(settings.api_keys, now)
|
|
84
|
+
async with store.unit() as unit:
|
|
85
|
+
usable = [k for k in await unit.api_keys.list_for_principals(admins) if k.is_usable(now)]
|
|
86
|
+
if usable and not force:
|
|
87
|
+
held = ", ".join(f"{k.principal_id} ({k.prefix}...)" for k in usable)
|
|
88
|
+
raise BootstrapError(
|
|
89
|
+
f"an installation administrator already holds a usable API key: {held}. "
|
|
90
|
+
"Use that key to issue others, or pass --force to add one more"
|
|
91
|
+
)
|
|
92
|
+
principal = await unit.principals.get(target)
|
|
93
|
+
created = principal is None
|
|
94
|
+
if principal is None:
|
|
95
|
+
principal = await unit.principals.add(
|
|
96
|
+
PrincipalType.SERVICE, display_name, principal_id=target
|
|
97
|
+
)
|
|
98
|
+
elif principal.type is not PrincipalType.SERVICE:
|
|
99
|
+
raise BootstrapError(
|
|
100
|
+
f"{target!r} is a {principal.type.value} principal; API keys belong to service "
|
|
101
|
+
"principals"
|
|
102
|
+
)
|
|
103
|
+
token, key = await issue_api_key(codec, unit.api_keys, principal.id, expires_at=expires_at)
|
|
104
|
+
await unit.audit.append(
|
|
105
|
+
NewAuditEntry(
|
|
106
|
+
actor=BOOTSTRAP_ACTOR,
|
|
107
|
+
action=BOOTSTRAP_ACTION,
|
|
108
|
+
target=key.id,
|
|
109
|
+
details={
|
|
110
|
+
"principal_id": principal.id,
|
|
111
|
+
"principal_created": created,
|
|
112
|
+
"prefix": key.prefix,
|
|
113
|
+
"forced": bool(usable),
|
|
114
|
+
"expires_at": expires_at.isoformat() if expires_at else None,
|
|
115
|
+
},
|
|
116
|
+
at=now,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
return BootstrapKey(
|
|
120
|
+
principal_id=principal.id,
|
|
121
|
+
principal_created=created,
|
|
122
|
+
key_id=key.id,
|
|
123
|
+
prefix=key.prefix,
|
|
124
|
+
token=token,
|
|
125
|
+
expires_at=expires_at,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def create_service_principal(
|
|
130
|
+
store: AuthStore, principal_id: str, display_name: str, *, now: datetime
|
|
131
|
+
) -> bool:
|
|
132
|
+
"""Add a service principal with this id; false when it already exists as one."""
|
|
133
|
+
async with store.unit() as unit:
|
|
134
|
+
existing = await unit.principals.get(principal_id)
|
|
135
|
+
if existing is not None:
|
|
136
|
+
if existing.type is not PrincipalType.SERVICE:
|
|
137
|
+
raise BootstrapError(
|
|
138
|
+
f"{principal_id!r} already exists as a {existing.type.value} principal"
|
|
139
|
+
)
|
|
140
|
+
return False
|
|
141
|
+
await unit.principals.add(PrincipalType.SERVICE, display_name, principal_id=principal_id)
|
|
142
|
+
await unit.audit.append(
|
|
143
|
+
NewAuditEntry(
|
|
144
|
+
actor=BOOTSTRAP_ACTOR,
|
|
145
|
+
action=PRINCIPAL_ACTION,
|
|
146
|
+
target=principal_id,
|
|
147
|
+
details={"type": PrincipalType.SERVICE.value, "display_name": display_name},
|
|
148
|
+
at=now,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
return True
|