lorebase-guide 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_guide/app.py ADDED
@@ -0,0 +1,451 @@
1
+ """The Guide HTTP service.
2
+
3
+ - `POST /embed` `{text, mode: query|document}` -> `{model, dims, vector, mode}`;
4
+ - `POST /explore` `{domain, branch?, question, max_steps?, budget?}` -> server-sent events:
5
+ `session`, then `thought`, `tool_call` and `tool_result` per step, then `answer` (or `error`
6
+ followed by `answer`), and `done`;
7
+ - `GET /healthz` (liveness) and `GET /readyz` (Keeper reachable).
8
+
9
+ Every request is authenticated with the caller's own credential and checked by Keeper before
10
+ any model work (see `auth.py`); limits per credential protect the model budget (`limits.py`).
11
+ Errors are problem documents (`application/problem+json`) with a `code`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import time
19
+ import uuid
20
+ from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
21
+ from contextlib import AsyncExitStack
22
+ from dataclasses import dataclass
23
+ from typing import Annotated, Any, Literal
24
+
25
+ from fastapi import FastAPI, Request
26
+ from fastapi.exceptions import RequestValidationError
27
+ from fastapi.middleware.cors import CORSMiddleware
28
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
29
+ from opentelemetry.trace import SpanKind
30
+ from pydantic import BaseModel, ConfigDict, Field
31
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
32
+
33
+ from lorebase_client.errors import LorebaseError
34
+ from lorebase_guide import __version__
35
+ from lorebase_guide.agent import Budget, Exploration, Explorer
36
+ from lorebase_guide.auth import AuthRefusedError, Caller, read_caller, verify
37
+ from lorebase_guide.embed import Embedder
38
+ from lorebase_guide.keeper import KeeperClients
39
+ from lorebase_guide.limits import Gate, Refusal, SlidingWindow, Slots
40
+ from lorebase_guide.settings import GuideSettings
41
+ from lorebase_guide.telemetry import (
42
+ EMBEDDINGS,
43
+ EXPLORATIONS,
44
+ REFUSED,
45
+ REQUEST_DURATION,
46
+ REQUESTS,
47
+ tracer,
48
+ )
49
+ from lorebase_guide.trace import TraceSession, new_session_id
50
+ from lorebase_inference import ChatModel, InferenceError, InferenceSettings, create_chat_model
51
+ from lorebase_platform.logging import request_context
52
+
53
+ logger = logging.getLogger("lorebase.guide")
54
+ access_logger = logging.getLogger("lorebase.guide.access")
55
+
56
+ NAME = r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
57
+ BRANCH = r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$"
58
+ PROBLEM = "application/problem+json"
59
+
60
+
61
+ class EmbedRequest(BaseModel):
62
+ model_config = ConfigDict(extra="forbid")
63
+
64
+ text: str = Field(min_length=1, description="Text to embed.")
65
+ mode: Literal["query", "document"] = Field(
66
+ default="query",
67
+ description="query: inference.embeddings.query_prefix is put before the text; "
68
+ "document: document_prefix, as Scribe does for Record texts.",
69
+ )
70
+
71
+
72
+ class EmbedResponse(BaseModel):
73
+ model: str = Field(description="Model name vectors are stored under (Keeper's model).")
74
+ dims: int
75
+ mode: Literal["query", "document"]
76
+ vector: list[float]
77
+
78
+
79
+ class ExploreRequest(BaseModel):
80
+ model_config = ConfigDict(extra="forbid")
81
+
82
+ domain: Annotated[str, Field(pattern=NAME, description="Domain id.")]
83
+ branch: Annotated[str | None, Field(default=None, pattern=BRANCH)] = None
84
+ question: str = Field(min_length=1)
85
+ max_steps: int | None = Field(
86
+ default=None, ge=1, le=50, description="Lower than guide.agent.max_steps only."
87
+ )
88
+ budget: int | None = Field(
89
+ default=None,
90
+ ge=100,
91
+ description="Token budget; lower than guide.agent.max_total_tokens only.",
92
+ )
93
+
94
+
95
+ def problem(
96
+ status: int, code: str, detail: str, *, headers: dict[str, str] | None = None
97
+ ) -> JSONResponse:
98
+ return JSONResponse(
99
+ {"type": "about:blank", "title": code, "status": status, "code": code, "detail": detail},
100
+ status_code=status,
101
+ media_type=PROBLEM,
102
+ headers=headers,
103
+ )
104
+
105
+
106
+ def _refused(refusal: Refusal) -> JSONResponse:
107
+ REFUSED.add(1, {"reason": refusal.reason})
108
+ detail = {
109
+ "rate": "too many requests from this caller; retry later",
110
+ "concurrent": "this caller already has an exploration running",
111
+ "busy": "Guide is busy; retry later",
112
+ }.get(refusal.reason, "refused")
113
+ return problem(429, refusal.reason, detail, headers={"Retry-After": str(refusal.retry_after_s)})
114
+
115
+
116
+ def _auth_refused(exc: AuthRefusedError) -> JSONResponse:
117
+ REFUSED.add(1, {"reason": exc.code})
118
+ headers = {"WWW-Authenticate": "Bearer"} if exc.status == 401 else None
119
+ return problem(exc.status, exc.code, exc.message, headers=headers)
120
+
121
+
122
+ def sse(name: str, data: dict[str, Any]) -> bytes:
123
+ return f"event: {name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n".encode()
124
+
125
+
126
+ class _EventStream(StreamingResponse):
127
+ """A stream whose resources (concurrency slot, Keeper client) are released however the
128
+ response ends, also when the client goes away before the first event."""
129
+
130
+ def __init__(
131
+ self,
132
+ content: AsyncIterator[bytes],
133
+ cleanup: Callable[[], Awaitable[None]],
134
+ **kwargs: Any,
135
+ ) -> None:
136
+ super().__init__(content, **kwargs)
137
+ self._cleanup = cleanup
138
+
139
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
140
+ try:
141
+ await super().__call__(scope, receive, send)
142
+ finally:
143
+ await self._cleanup()
144
+
145
+
146
+ @dataclass
147
+ class Runtime:
148
+ settings: GuideSettings
149
+ keepers: KeeperClients
150
+ embedder: Embedder
151
+ chat: Callable[[], ChatModel]
152
+ explore_window: SlidingWindow
153
+ embed_window: SlidingWindow
154
+ slots: Slots
155
+ verifying: Gate
156
+ _explorer: Explorer | None = None
157
+
158
+ def explorer(self) -> Explorer:
159
+ if self._explorer is None:
160
+ self._explorer = Explorer(self.chat(), self.settings.agent, self.embedder)
161
+ return self._explorer
162
+
163
+
164
+ class AccessMiddleware:
165
+ """Request id, server span, body size limit, timing and an access log line without
166
+ values (method, route, status, duration)."""
167
+
168
+ def __init__(self, app: ASGIApp, *, max_body_bytes: int) -> None:
169
+ self._app = app
170
+ self._max_body = max_body_bytes
171
+
172
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
173
+ if scope["type"] != "http":
174
+ await self._app(scope, receive, send)
175
+ return
176
+ request_id = uuid.uuid4().hex
177
+ path = scope.get("path", "")
178
+ started = time.monotonic()
179
+ status = 500
180
+
181
+ async def tracked_send(message: Message) -> None:
182
+ nonlocal status
183
+ if message["type"] == "http.response.start":
184
+ status = message["status"]
185
+ headers = list(message.get("headers", []))
186
+ headers.append((b"x-request-id", request_id.encode()))
187
+ headers.append((b"x-content-type-options", b"nosniff"))
188
+ message = {**message, "headers": headers}
189
+ await send(message)
190
+
191
+ declared = _content_length(scope)
192
+ refusal: JSONResponse | None = None
193
+ if scope.get("method") == "POST":
194
+ if declared is None:
195
+ refusal = problem(411, "length_required", "send the body with a Content-Length")
196
+ elif declared > self._max_body:
197
+ refusal = problem(413, "payload_too_large", "request body too large")
198
+ with (
199
+ request_context(request_id),
200
+ tracer.start_as_current_span(
201
+ f"{scope.get('method', '')} {path}", kind=SpanKind.SERVER
202
+ ) as span,
203
+ ):
204
+ try:
205
+ if refusal is not None:
206
+ await refusal(scope, receive, tracked_send)
207
+ else:
208
+ await self._app(scope, _capped(receive, self._max_body), tracked_send)
209
+ finally:
210
+ elapsed = time.monotonic() - started
211
+ span.set_attribute("http.response.status_code", status)
212
+ route = path if path in _ROUTES else "other"
213
+ REQUESTS.add(1, {"route": route, "status": status})
214
+ REQUEST_DURATION.record(elapsed, {"route": route})
215
+ access_logger.info(
216
+ "request",
217
+ extra={
218
+ "method": scope.get("method"),
219
+ "route": route,
220
+ "status": status,
221
+ "duration_ms": round(elapsed * 1000),
222
+ },
223
+ )
224
+
225
+
226
+ def _capped(receive: Receive, limit: int) -> Receive:
227
+ """`receive` that ends the body once `limit` bytes were read (a Content-Length that lies
228
+ cannot make the service read more)."""
229
+ received = 0
230
+
231
+ async def capped() -> Message:
232
+ nonlocal received
233
+ message = await receive()
234
+ if message["type"] == "http.request":
235
+ body = message.get("body", b"")
236
+ if received + len(body) > limit:
237
+ body = body[: max(0, limit - received)]
238
+ message = {"type": "http.request", "body": body, "more_body": False}
239
+ received += len(body)
240
+ return message
241
+
242
+ return capped
243
+
244
+
245
+ _ROUTES = frozenset({"/embed", "/explore", "/healthz", "/readyz"})
246
+
247
+
248
+ def _content_length(scope: Scope) -> int | None:
249
+ for name, value in scope.get("headers", []):
250
+ if name == b"content-length":
251
+ try:
252
+ return int(value)
253
+ except ValueError:
254
+ return None
255
+ return None
256
+
257
+
258
+ def _headers(request: Request) -> dict[str, str]:
259
+ return {k.lower(): v for k, v in request.headers.items()}
260
+
261
+
262
+ def create_app(
263
+ settings: GuideSettings,
264
+ inference: InferenceSettings,
265
+ *,
266
+ chat: Callable[[], ChatModel] | None = None,
267
+ embedder: Embedder | None = None,
268
+ keeper_transport: Any = None,
269
+ clock: Callable[[], float] = time.monotonic,
270
+ ) -> FastAPI:
271
+ """The Guide application. `chat`, `embedder` and `keeper_transport` replace the configured
272
+ model, embedding model and network (tests)."""
273
+ runtime = Runtime(
274
+ settings=settings,
275
+ keepers=KeeperClients(settings, transport=keeper_transport),
276
+ embedder=embedder or Embedder(inference),
277
+ chat=chat or (lambda: create_chat_model(inference, task=settings.agent.task)),
278
+ explore_window=SlidingWindow(
279
+ settings.limits.explorations_per_minute,
280
+ clock,
281
+ max_keys=settings.limits.max_tracked_callers,
282
+ ),
283
+ embed_window=SlidingWindow(
284
+ settings.limits.embeds_per_minute,
285
+ clock,
286
+ max_keys=settings.limits.max_tracked_callers,
287
+ ),
288
+ slots=Slots(settings.limits.max_concurrent, settings.limits.max_concurrent_per_caller),
289
+ verifying=Gate(settings.limits.max_concurrent_verifications),
290
+ )
291
+ app = FastAPI(
292
+ title="Lorebase Guide",
293
+ version=__version__,
294
+ docs_url=None,
295
+ redoc_url=None,
296
+ openapi_url=None,
297
+ )
298
+ app.state.runtime = runtime
299
+ http = settings.http
300
+ if http.origins_fell_back:
301
+ logger.warning(
302
+ "guide.http.allowed_origins is an empty list; using guide.http.default_origins",
303
+ extra={"origins": list(http.origins)},
304
+ )
305
+
306
+ @app.exception_handler(RequestValidationError)
307
+ async def _invalid(request: Request, exc: RequestValidationError) -> JSONResponse:
308
+ problems = "; ".join(
309
+ f"{'.'.join(str(p) for p in e['loc'][1:]) or 'body'}: {e['msg']}"
310
+ for e in exc.errors()[:5]
311
+ )
312
+ return problem(422, "invalid_request", problems[:500])
313
+
314
+ @app.get("/healthz")
315
+ async def healthz() -> dict[str, str]:
316
+ return {"status": "ok"}
317
+
318
+ @app.get("/readyz")
319
+ async def readyz() -> JSONResponse:
320
+ ok = await runtime.keepers.probe(settings.readiness_timeout_s)
321
+ body = {"status": "ready" if ok else "not_ready", "checks": {"keeper": ok}}
322
+ return JSONResponse(body, status_code=200 if ok else 503)
323
+
324
+ async def _verified(caller: Caller) -> Caller | JSONResponse:
325
+ if not runtime.verifying.enter():
326
+ return _refused(Refusal("busy", 1))
327
+ try:
328
+ async with runtime.keepers.open(caller) as client:
329
+ return await verify(caller, client)
330
+ except AuthRefusedError as exc:
331
+ return _auth_refused(exc)
332
+ except LorebaseError:
333
+ return problem(502, "keeper_unavailable", "Keeper could not be reached")
334
+ finally:
335
+ runtime.verifying.leave()
336
+
337
+ @app.post("/embed", response_model=EmbedResponse)
338
+ async def embed(body: EmbedRequest, request: Request) -> Response:
339
+ try:
340
+ caller = read_caller(_headers(request), http)
341
+ except AuthRefusedError as exc:
342
+ return _auth_refused(exc)
343
+ if len(body.text) > settings.limits.max_embed_chars:
344
+ return problem(422, "text_too_long", "text is longer than allowed")
345
+ verified = await _verified(caller)
346
+ if isinstance(verified, JSONResponse):
347
+ return verified
348
+ refusal = runtime.embed_window.take(verified.identity)
349
+ if refusal is not None:
350
+ return _refused(refusal)
351
+ try:
352
+ result = await runtime.embedder.embed(body.text, body.mode)
353
+ except InferenceError as exc:
354
+ logger.warning("embedding failed", extra={"error": type(exc).__name__})
355
+ return problem(502, "embedding_failed", "the embedding model failed")
356
+ EMBEDDINGS.add(1, {"mode": body.mode})
357
+ payload = EmbedResponse(
358
+ model=result.model, dims=result.dims, mode=body.mode, vector=result.vector
359
+ )
360
+ return JSONResponse(payload.model_dump(), headers={"Cache-Control": "no-store"})
361
+
362
+ @app.post("/explore")
363
+ async def explore(body: ExploreRequest, request: Request) -> Response:
364
+ try:
365
+ caller = read_caller(_headers(request), http)
366
+ except AuthRefusedError as exc:
367
+ return _auth_refused(exc)
368
+ if len(body.question) > settings.agent.max_question_chars:
369
+ return problem(422, "question_too_long", "the question is longer than allowed")
370
+ if not runtime.verifying.enter():
371
+ return _refused(Refusal("busy", 1))
372
+ stack = AsyncExitStack()
373
+ try:
374
+ trace = TraceSession(settings.trace, new_session_id(), body.domain)
375
+ client = await stack.enter_async_context(runtime.keepers.open(caller, trace.headers()))
376
+ caller = await verify(caller, client)
377
+ explorer = runtime.explorer()
378
+ except AuthRefusedError as exc:
379
+ await stack.aclose()
380
+ return _auth_refused(exc)
381
+ except LorebaseError:
382
+ await stack.aclose()
383
+ return problem(502, "keeper_unavailable", "Keeper could not be reached")
384
+ except BaseException:
385
+ await stack.aclose()
386
+ raise
387
+ finally:
388
+ runtime.verifying.leave()
389
+ # Limits count verified callers only, keyed on the principal Keeper reported.
390
+ key = caller.identity
391
+ refusal = runtime.explore_window.take(key) or runtime.slots.acquire(key)
392
+ if refusal is not None:
393
+ await stack.aclose()
394
+ return _refused(refusal)
395
+ stack.callback(runtime.slots.release, key)
396
+ budget = Budget.of(settings.agent, max_steps=body.max_steps, budget=body.budget)
397
+ state = Exploration()
398
+
399
+ async def stream() -> AsyncGenerator[bytes]:
400
+ started = time.monotonic()
401
+ try:
402
+ async for event in explorer.run(
403
+ client=client,
404
+ trace=trace,
405
+ domain=body.domain,
406
+ branch=body.branch,
407
+ question=body.question,
408
+ budget=budget,
409
+ state=state,
410
+ ):
411
+ yield sse(event.name, event.data)
412
+ finally:
413
+ if state.outcome == "running":
414
+ state.outcome = "aborted"
415
+ EXPLORATIONS.add(1, {"outcome": state.outcome})
416
+ logger.info(
417
+ "exploration finished",
418
+ extra={
419
+ "session": trace.session_id,
420
+ "outcome": state.outcome,
421
+ "steps": state.steps,
422
+ "input_tokens": state.usage.input_tokens,
423
+ "output_tokens": state.usage.output_tokens,
424
+ "duration_ms": round((time.monotonic() - started) * 1000),
425
+ },
426
+ )
427
+ await stack.aclose()
428
+
429
+ events = stream()
430
+
431
+ async def cleanup() -> None:
432
+ await events.aclose()
433
+ await stack.aclose()
434
+
435
+ return _EventStream(
436
+ events,
437
+ cleanup,
438
+ media_type="text/event-stream",
439
+ headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
440
+ )
441
+
442
+ app.add_middleware(
443
+ CORSMiddleware,
444
+ allow_origins=list(http.origins),
445
+ allow_methods=["GET", "POST"],
446
+ allow_headers=[http.credential_header, http.csrf_header, "Content-Type"],
447
+ allow_credentials=bool(http.session_cookie),
448
+ max_age=600,
449
+ )
450
+ app.add_middleware(AccessMiddleware, max_body_bytes=http.max_body_bytes)
451
+ return app
lorebase_guide/auth.py ADDED
@@ -0,0 +1,183 @@
1
+ """The caller's credential: read from the request, checked by Keeper, forwarded unchanged.
2
+
3
+ Guide has no Keeper credential of its own and never substitutes one. A caller authenticates
4
+ in one of three ways:
5
+
6
+ - `bearer`: the credential header (`Authorization: Bearer <OIDC token or API key>`, a bare API
7
+ key is accepted too). Sent to Keeper as is.
8
+ - `session`: Keeper's session cookie (`guide.http.session_cookie`) with its CSRF token in
9
+ `guide.http.csrf_header`, when Guide is served under the web app's origin. Only that one
10
+ cookie and the token are forwarded; Guide asks Keeper for the session's token
11
+ (`GET /auth/session`) and refuses the request unless the two match, so a cross-site page
12
+ cannot spend the user's model budget with the cookie alone.
13
+ - `anonymous`: no credential, only with `guide.http.allow_anonymous` (Keeper in auth mode none).
14
+
15
+ Before any model call, `verify` asks Keeper who the caller is: an unknown or expired
16
+ credential is refused before it costs tokens. A request carrying an `Origin` that is not in
17
+ `guide.http.allowed_origins` (or `default_origins` while that is empty), or marked cross-site by
18
+ the browser, is refused.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import hmac
25
+ import re
26
+ from collections.abc import Mapping
27
+ from dataclasses import dataclass
28
+ from http.cookies import CookieError, SimpleCookie
29
+ from typing import Any, Literal
30
+
31
+ from lorebase_client import AsyncLorebaseClient
32
+ from lorebase_client.core import Call, parse_json
33
+ from lorebase_client.errors import LorebaseError
34
+ from lorebase_guide.settings import HttpSettings
35
+
36
+ CredentialKind = Literal["bearer", "session", "anonymous"]
37
+ _MAX_CREDENTIAL_CHARS = 8_192
38
+ _CROSS_SITE = "cross-site"
39
+ _SESSION_ID = re.compile(r"[A-Za-z0-9_-]{32,128}")
40
+ """Shape of Keeper's session ids; any other cookie value is refused, never forwarded."""
41
+ _CSRF_TOKEN = re.compile(r"[A-Za-z0-9_.~+/=-]{1,256}")
42
+
43
+
44
+ class AuthRefusedError(Exception):
45
+ """The request is refused before any work: `status` is 401 or 403."""
46
+
47
+ def __init__(self, status: int, code: str, message: str) -> None:
48
+ super().__init__(message)
49
+ self.status = status
50
+ self.code = code
51
+ self.message = message
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Caller:
56
+ """The credential of one request. Its repr never shows the secret parts."""
57
+
58
+ kind: CredentialKind
59
+ authorization: str | None = None
60
+ session_id: str | None = None
61
+ csrf_token: str | None = None
62
+ principal: str | None = None
63
+ """Principal id reported by Keeper once verified."""
64
+
65
+ def __repr__(self) -> str:
66
+ return f"Caller(kind={self.kind!r}, principal={self.principal!r})"
67
+
68
+ @property
69
+ def identity(self) -> str:
70
+ """Key of the per-caller limits: the verified principal, else the credential's
71
+ fingerprint (anonymous callers share one key)."""
72
+ return f"principal:{self.principal}" if self.principal else self.fingerprint
73
+
74
+ @property
75
+ def fingerprint(self) -> str:
76
+ """Stable, non-reversible id of the credential, used for per-caller limits."""
77
+ secret = self.authorization or self.session_id or ""
78
+ if not secret:
79
+ return "anonymous"
80
+ return hashlib.sha256(f"{self.kind}\x00{secret}".encode()).hexdigest()[:32]
81
+
82
+ def keeper_headers(self, http: HttpSettings) -> dict[str, str]:
83
+ """Headers that carry this credential to Keeper, and nothing else of the request."""
84
+ if self.kind == "bearer" and self.authorization:
85
+ return {"Authorization": self.authorization}
86
+ if self.kind == "session" and self.session_id and self.csrf_token:
87
+ return {
88
+ "Cookie": f"{http.session_cookie}={self.session_id}",
89
+ http.csrf_header: self.csrf_token,
90
+ }
91
+ return {}
92
+
93
+
94
+ def _single_line(value: str) -> bool:
95
+ return "\r" not in value and "\n" not in value and len(value) <= _MAX_CREDENTIAL_CHARS
96
+
97
+
98
+ def _cookie(headers: Mapping[str, str], name: str) -> str | None:
99
+ raw = headers.get("cookie")
100
+ if not raw or not name:
101
+ return None
102
+ jar: SimpleCookie = SimpleCookie()
103
+ try:
104
+ jar.load(raw)
105
+ except CookieError:
106
+ return None
107
+ morsel = jar.get(name)
108
+ return morsel.value if morsel is not None and morsel.value else None
109
+
110
+
111
+ def check_origin(headers: Mapping[str, str], http: HttpSettings) -> None:
112
+ """Refuse browser requests from origins that are not configured, and cross-site ones."""
113
+ origin = headers.get("origin")
114
+ if origin is not None and origin.rstrip("/").lower() not in http.origins:
115
+ raise AuthRefusedError(
116
+ 403, "origin_not_allowed", "requests from this origin are not allowed"
117
+ )
118
+ site = headers.get("sec-fetch-site")
119
+ if site is not None and site.lower() == _CROSS_SITE and origin is None:
120
+ raise AuthRefusedError(403, "cross_site", "cross-site requests are not allowed")
121
+
122
+
123
+ def read_caller(headers: Mapping[str, str], http: HttpSettings) -> Caller:
124
+ """The credential of a request (lower-case header names), not yet verified."""
125
+ check_origin(headers, http)
126
+ authorization = headers.get(http.credential_header.lower())
127
+ if authorization is not None and authorization.strip():
128
+ value = authorization.strip()
129
+ if not _single_line(value):
130
+ raise AuthRefusedError(401, "unauthorized", "malformed credential")
131
+ scheme, _, rest = value.partition(" ")
132
+ if scheme.lower() == "bearer":
133
+ if not rest.strip():
134
+ raise AuthRefusedError(401, "unauthorized", "malformed credential")
135
+ value = f"Bearer {rest.strip()}"
136
+ elif rest:
137
+ raise AuthRefusedError(401, "unauthorized", "only Bearer credentials are accepted")
138
+ else:
139
+ value = f"Bearer {value}"
140
+ return Caller(kind="bearer", authorization=value)
141
+ session_id = _cookie(headers, http.session_cookie)
142
+ if session_id is not None:
143
+ token = (headers.get(http.csrf_header.lower()) or "").strip()
144
+ if not _SESSION_ID.fullmatch(session_id):
145
+ raise AuthRefusedError(401, "unauthorized", "malformed session cookie")
146
+ if not _CSRF_TOKEN.fullmatch(token):
147
+ raise AuthRefusedError(
148
+ 403, "csrf", f"a session cookie needs its CSRF token in {http.csrf_header}"
149
+ )
150
+ return Caller(kind="session", session_id=session_id, csrf_token=token)
151
+ if http.allow_anonymous:
152
+ return Caller(kind="anonymous")
153
+ raise AuthRefusedError(
154
+ 401,
155
+ "unauthorized",
156
+ f"send `{http.credential_header}: Bearer <token or API key>` or the session cookie",
157
+ )
158
+
159
+
160
+ async def verify(caller: Caller, client: AsyncLorebaseClient) -> Caller:
161
+ """Ask Keeper who the caller is, with the caller's own credential; for a session, also
162
+ check the CSRF token against the session's. Returns the caller with its principal."""
163
+ try:
164
+ who = await client.whoami()
165
+ if caller.kind == "session":
166
+ session: Any = await client.send(Call("GET", "/auth/session", parse_json, safe=True))
167
+ expected = str(session.get("csrf_token") or "") if isinstance(session, dict) else ""
168
+ given = caller.csrf_token or ""
169
+ if not expected or not hmac.compare_digest(expected.encode(), given.encode()):
170
+ raise AuthRefusedError(403, "csrf", "the CSRF token does not match the session")
171
+ except LorebaseError as exc:
172
+ if exc.status in (401, 403):
173
+ raise AuthRefusedError(
174
+ exc.status, "unauthorized", "Keeper refused the credential"
175
+ ) from None
176
+ raise
177
+ return Caller(
178
+ kind=caller.kind,
179
+ authorization=caller.authorization,
180
+ session_id=caller.session_id,
181
+ csrf_token=caller.csrf_token,
182
+ principal=who.id or None,
183
+ )