curu-memory 0.2.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.
- curu_memory/__init__.py +3 -0
- curu_memory/__main__.py +1032 -0
- curu_memory/access.py +562 -0
- curu_memory/audit.py +63 -0
- curu_memory/auth.py +41 -0
- curu_memory/browser.py +396 -0
- curu_memory/config.py +308 -0
- curu_memory/embedders.py +118 -0
- curu_memory/errors.py +96 -0
- curu_memory/indexing.py +440 -0
- curu_memory/media.py +199 -0
- curu_memory/oidc.py +437 -0
- curu_memory/okf.py +271 -0
- curu_memory/ports.py +344 -0
- curu_memory/render.py +61 -0
- curu_memory/retrieval.py +570 -0
- curu_memory/server.py +272 -0
- curu_memory/store_sqlite.py +551 -0
- curu_memory-0.2.0.dist-info/METADATA +723 -0
- curu_memory-0.2.0.dist-info/RECORD +23 -0
- curu_memory-0.2.0.dist-info/WHEEL +4 -0
- curu_memory-0.2.0.dist-info/entry_points.txt +2 -0
- curu_memory-0.2.0.dist-info/licenses/LICENSE +21 -0
curu_memory/oidc.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""The check of an OIDC access token on the HTTP transport (ADR-015, ADR-017).
|
|
2
|
+
|
|
3
|
+
curu-memory is a resource server. It never issues a token. It reads the keys of one
|
|
4
|
+
issuer, and it accepts a token only when each rule here passes. The key sets the
|
|
5
|
+
algorithm, not the token header, and each HMAC algorithm and `none` are refused.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections import OrderedDict
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
import anyio
|
|
19
|
+
import httpx2
|
|
20
|
+
import jwt
|
|
21
|
+
from mcp.server.auth.middleware.auth_context import get_access_token
|
|
22
|
+
from mcp.server.auth.provider import AccessToken
|
|
23
|
+
|
|
24
|
+
from curu_memory.access import ANONYMOUS, AccessStore, Principal
|
|
25
|
+
from curu_memory.audit import REFUSED, AuditLog
|
|
26
|
+
|
|
27
|
+
#: The algorithms of a key that curu-memory accepts. No HMAC and no `none` (ADR-015).
|
|
28
|
+
ALLOWED_ALGORITHMS = frozenset({"RS256", "RS384", "RS512", "PS256", "ES256", "EdDSA"})
|
|
29
|
+
#: The clock skew in seconds for `exp`, `nbf` and `iat`.
|
|
30
|
+
LEEWAY = 60
|
|
31
|
+
#: The longest life of a token, from `iat` (or `nbf`) to `exp`, in seconds.
|
|
32
|
+
MAX_LIFETIME = 3600
|
|
33
|
+
#: The shortest time between two reads of the keys for an unknown key identifier.
|
|
34
|
+
REFRESH_SECONDS = 60
|
|
35
|
+
#: The shortest time between two reads after a failed read, in seconds.
|
|
36
|
+
FAILED_READ_SECONDS = 10
|
|
37
|
+
_LOOPBACK = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
38
|
+
#: The token type of Authentik. With it, a token must also have `azp` and `scope` (ADR-017).
|
|
39
|
+
PLAIN_JWT = "JWT"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TokenRefused(Exception):
|
|
43
|
+
"""The token does not pass a rule. `reason` is a short text with no part of the token."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, reason: str) -> None:
|
|
46
|
+
super().__init__(reason)
|
|
47
|
+
self.reason = reason
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class KeyCache:
|
|
51
|
+
"""The keys of one issuer, read from its discovery document and its JWKS."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, issuer: str, *, timeout: float = 5.0) -> None:
|
|
54
|
+
self._issuer = issuer
|
|
55
|
+
self._timeout = timeout
|
|
56
|
+
self._keys: dict[str, jwt.PyJWK] = {}
|
|
57
|
+
self._jwks_uri: str | None = None
|
|
58
|
+
self._read_at: float | None = None
|
|
59
|
+
self._read_ok = False
|
|
60
|
+
self._discovery: dict[str, Any] | None = None
|
|
61
|
+
self._lock = threading.Lock()
|
|
62
|
+
|
|
63
|
+
def key(self, kid: str | None) -> jwt.PyJWK:
|
|
64
|
+
"""The key of `kid`. An unknown `kid` reads the keys again, at most each 60 seconds.
|
|
65
|
+
|
|
66
|
+
A known `kid` never waits for a read. Only one request reads at a time. Another
|
|
67
|
+
request with an unknown `kid` does not wait for it: it is refused at once.
|
|
68
|
+
"""
|
|
69
|
+
if not isinstance(kid, str) or not kid:
|
|
70
|
+
raise TokenRefused("unknown key")
|
|
71
|
+
found = self._keys.get(kid)
|
|
72
|
+
if found is not None:
|
|
73
|
+
return found
|
|
74
|
+
# Before the first read, a request waits, so that the first requests of a new
|
|
75
|
+
# server do not fail together. After it, a request never waits for a read.
|
|
76
|
+
if self._lock.acquire(blocking=self._read_at is None):
|
|
77
|
+
try:
|
|
78
|
+
if kid not in self._keys and self._may_read():
|
|
79
|
+
self._read()
|
|
80
|
+
finally:
|
|
81
|
+
self._lock.release()
|
|
82
|
+
found = self._keys.get(kid)
|
|
83
|
+
if found is None:
|
|
84
|
+
raise TokenRefused("unknown key")
|
|
85
|
+
return found
|
|
86
|
+
|
|
87
|
+
def _may_read(self) -> bool:
|
|
88
|
+
if self._read_at is None:
|
|
89
|
+
return True
|
|
90
|
+
wait = REFRESH_SECONDS if self._read_ok else FAILED_READ_SECONDS
|
|
91
|
+
return time.monotonic() - self._read_at >= wait
|
|
92
|
+
|
|
93
|
+
def _read(self) -> None:
|
|
94
|
+
# The time of each read counts, also of a failed one, so a down issuer gets at
|
|
95
|
+
# most one read each FAILED_READ_SECONDS (review of spec 012).
|
|
96
|
+
self._read_at = time.monotonic()
|
|
97
|
+
self._read_ok = False
|
|
98
|
+
try:
|
|
99
|
+
if self._jwks_uri is None:
|
|
100
|
+
self._jwks_uri = _safe_url(str(self.discovery()["jwks_uri"]))
|
|
101
|
+
document = self._get(self._jwks_uri)
|
|
102
|
+
except (httpx2.HTTPError, KeyError, ValueError) as error:
|
|
103
|
+
raise TokenRefused("issuer not available") from error
|
|
104
|
+
keys: dict[str, jwt.PyJWK] = {}
|
|
105
|
+
for entry in document.get("keys", []):
|
|
106
|
+
kid = entry.get("kid")
|
|
107
|
+
if not isinstance(kid, str) or not kid or entry.get("use", "sig") != "sig":
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
keys[kid] = jwt.PyJWK(entry)
|
|
111
|
+
except jwt.PyJWKError:
|
|
112
|
+
continue
|
|
113
|
+
self._keys = keys
|
|
114
|
+
self._read_ok = True
|
|
115
|
+
|
|
116
|
+
def discovery(self) -> dict[str, Any]:
|
|
117
|
+
"""The discovery document of the issuer, read one time.
|
|
118
|
+
|
|
119
|
+
Raise `ValueError` when the document names another issuer (OIDC Discovery 1.0,
|
|
120
|
+
section 4.3), or `httpx2.HTTPError` when the issuer does not answer.
|
|
121
|
+
"""
|
|
122
|
+
if self._discovery is None:
|
|
123
|
+
document = self._get(f"{self._issuer.rstrip('/')}/.well-known/openid-configuration")
|
|
124
|
+
if document.get("issuer") != self._issuer:
|
|
125
|
+
raise ValueError("the discovery document names another issuer")
|
|
126
|
+
self._discovery = document
|
|
127
|
+
return self._discovery
|
|
128
|
+
|
|
129
|
+
def _get(self, url: str) -> dict[str, Any]:
|
|
130
|
+
response = httpx2.get(url, timeout=self._timeout, follow_redirects=False)
|
|
131
|
+
response.raise_for_status()
|
|
132
|
+
return response.json()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _safe_url(url: str) -> str:
|
|
136
|
+
"""The URL of the keys. Plain http is only for loopback, as for the issuer (R-5)."""
|
|
137
|
+
parts = urlsplit(url)
|
|
138
|
+
if parts.scheme == "https" or (parts.scheme == "http" and parts.hostname in _LOOPBACK):
|
|
139
|
+
return url
|
|
140
|
+
raise ValueError("the keys must use https")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def verify_token(
|
|
144
|
+
token: str,
|
|
145
|
+
*,
|
|
146
|
+
issuer: str,
|
|
147
|
+
audience: str,
|
|
148
|
+
token_types: tuple[str, ...],
|
|
149
|
+
keys: KeyCache,
|
|
150
|
+
) -> dict[str, Any]:
|
|
151
|
+
"""The claims of a token that passes each rule. Else raise `TokenRefused`."""
|
|
152
|
+
try:
|
|
153
|
+
header = jwt.get_unverified_header(token)
|
|
154
|
+
except jwt.InvalidTokenError:
|
|
155
|
+
raise TokenRefused("malformed token") from None
|
|
156
|
+
algorithm = str(header.get("alg", ""))
|
|
157
|
+
if algorithm.lower() == "none" or algorithm.upper().startswith("HS"):
|
|
158
|
+
raise TokenRefused("algorithm not allowed")
|
|
159
|
+
token_type = header.get("typ")
|
|
160
|
+
if token_type is not None and token_type not in token_types:
|
|
161
|
+
raise TokenRefused("wrong token type")
|
|
162
|
+
key = keys.key(header.get("kid"))
|
|
163
|
+
# The key sets the algorithm. A header that names another one does not pass.
|
|
164
|
+
if key.algorithm_name not in ALLOWED_ALGORITHMS or algorithm != key.algorithm_name:
|
|
165
|
+
raise TokenRefused("algorithm not allowed")
|
|
166
|
+
try:
|
|
167
|
+
claims = jwt.decode(
|
|
168
|
+
token,
|
|
169
|
+
key.key,
|
|
170
|
+
algorithms=[key.algorithm_name],
|
|
171
|
+
audience=audience,
|
|
172
|
+
issuer=issuer,
|
|
173
|
+
leeway=LEEWAY,
|
|
174
|
+
options={"require": ["exp", "iss", "aud"]},
|
|
175
|
+
)
|
|
176
|
+
except jwt.ExpiredSignatureError:
|
|
177
|
+
raise TokenRefused("expired") from None
|
|
178
|
+
except jwt.ImmatureSignatureError:
|
|
179
|
+
raise TokenRefused("not yet valid") from None
|
|
180
|
+
except jwt.InvalidAudienceError:
|
|
181
|
+
raise TokenRefused("wrong audience") from None
|
|
182
|
+
except jwt.InvalidIssuerError:
|
|
183
|
+
raise TokenRefused("wrong issuer") from None
|
|
184
|
+
except jwt.InvalidSignatureError:
|
|
185
|
+
raise TokenRefused("bad signature") from None
|
|
186
|
+
except jwt.MissingRequiredClaimError:
|
|
187
|
+
raise TokenRefused("missing claim") from None
|
|
188
|
+
except jwt.InvalidTokenError:
|
|
189
|
+
raise TokenRefused("malformed token") from None
|
|
190
|
+
start = claims.get("iat", claims.get("nbf"))
|
|
191
|
+
if isinstance(start, int | float) and claims["exp"] - start > MAX_LIFETIME:
|
|
192
|
+
raise TokenRefused("lifetime too long")
|
|
193
|
+
# An ID token of Authentik has the same `aud`, but no `azp` and `scope` (ADR-017).
|
|
194
|
+
# A token with no typ gets the same rule, so an issuer cannot skip it.
|
|
195
|
+
plain = token_type == PLAIN_JWT or (token_type is None and PLAIN_JWT in token_types)
|
|
196
|
+
if plain and not (claims.get("azp") and claims.get("scope")):
|
|
197
|
+
raise TokenRefused("missing claim")
|
|
198
|
+
return claims
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class PrincipalToken(AccessToken):
|
|
202
|
+
"""The token of one request, with the handle of its person (spec 012).
|
|
203
|
+
|
|
204
|
+
The SDK keeps it for the request. It holds no part of the bearer token.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
#: The handle of the person. For an agent, the person that it acts for.
|
|
208
|
+
handle: str
|
|
209
|
+
#: The name of the agent, or None for a person (spec 013).
|
|
210
|
+
agent: str | None = None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class OidcVerifier:
|
|
214
|
+
"""The token verifier of the HTTP transport in OIDC mode (ADR-015, ADR-017).
|
|
215
|
+
|
|
216
|
+
It finds the person of a valid token by its `iss` and `sub`. Each refused token writes
|
|
217
|
+
one audit line with the reason, and the SDK answers HTTP 401.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
*,
|
|
223
|
+
issuer: str,
|
|
224
|
+
audience: str,
|
|
225
|
+
token_types: tuple[str, ...],
|
|
226
|
+
access: AccessStore,
|
|
227
|
+
audit: AuditLog,
|
|
228
|
+
) -> None:
|
|
229
|
+
self._issuer = issuer
|
|
230
|
+
self._audience = audience
|
|
231
|
+
self._token_types = token_types
|
|
232
|
+
self._access = access
|
|
233
|
+
self._audit = audit
|
|
234
|
+
self._keys = KeyCache(issuer)
|
|
235
|
+
|
|
236
|
+
def __repr__(self) -> str:
|
|
237
|
+
return f"OidcVerifier(issuer={self._issuer!r})"
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def keys(self) -> KeyCache:
|
|
241
|
+
"""The key cache, which also holds the discovery document of the issuer."""
|
|
242
|
+
return self._keys
|
|
243
|
+
|
|
244
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
245
|
+
return await anyio.to_thread.run_sync(self._verify, token)
|
|
246
|
+
|
|
247
|
+
def _verify(self, token: str) -> PrincipalToken | None:
|
|
248
|
+
try:
|
|
249
|
+
claims = verify_token(
|
|
250
|
+
token,
|
|
251
|
+
issuer=self._issuer,
|
|
252
|
+
audience=self._audience,
|
|
253
|
+
token_types=self._token_types,
|
|
254
|
+
keys=self._keys,
|
|
255
|
+
)
|
|
256
|
+
except TokenRefused as refused:
|
|
257
|
+
self._refuse(refused.reason)
|
|
258
|
+
return None
|
|
259
|
+
subject = claims.get("sub")
|
|
260
|
+
client = claims.get("client_id") or claims.get("azp")
|
|
261
|
+
# ADR-017: a person by sub, an agent by sub, an agent by client identifier.
|
|
262
|
+
person = self._access.person_by_subject(self._issuer, str(subject)) if subject else None
|
|
263
|
+
by_subject = self._access.agent_by_subject(self._issuer, str(subject)) if subject else None
|
|
264
|
+
# ADR-018: only a token of the client itself (no sub, or sub is the client) is the
|
|
265
|
+
# agent of that client. Else each account that logs in through the client would be it.
|
|
266
|
+
own = client and (not subject or str(subject) == str(client))
|
|
267
|
+
by_client = self._access.agent_by_client(self._issuer, str(client)) if own else None
|
|
268
|
+
found = [p for p in (person, by_subject, by_client) if p is not None]
|
|
269
|
+
if not found:
|
|
270
|
+
self._refuse("unknown subject")
|
|
271
|
+
return None
|
|
272
|
+
if len(found) > 1:
|
|
273
|
+
self._refuse("more than one principal")
|
|
274
|
+
return None
|
|
275
|
+
agent = by_subject or by_client
|
|
276
|
+
if agent is not None:
|
|
277
|
+
handle = agent.person
|
|
278
|
+
else:
|
|
279
|
+
assert person is not None
|
|
280
|
+
handle = person.handle
|
|
281
|
+
scope = claims.get("scope", "")
|
|
282
|
+
return PrincipalToken(
|
|
283
|
+
token="oidc",
|
|
284
|
+
client_id=str(claims.get("client_id") or claims.get("azp") or ""),
|
|
285
|
+
scopes=scope.split() if isinstance(scope, str) else [],
|
|
286
|
+
expires_at=int(claims["exp"]),
|
|
287
|
+
resource=self._audience,
|
|
288
|
+
# The SDK binds a session to the client, the issuer and the subject.
|
|
289
|
+
subject=str(subject or client),
|
|
290
|
+
claims={"iss": self._issuer},
|
|
291
|
+
handle=handle,
|
|
292
|
+
agent=agent.name if agent is not None else None,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def _refuse(self, reason: str) -> None:
|
|
296
|
+
self._audit.write("anonymous", "token", None, REFUSED, reason)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def principal_of_request() -> Principal:
|
|
300
|
+
"""The principal of the current HTTP request: a person, an agent, or anonymous."""
|
|
301
|
+
token = get_access_token()
|
|
302
|
+
if not isinstance(token, PrincipalToken):
|
|
303
|
+
return ANONYMOUS
|
|
304
|
+
if token.agent is not None:
|
|
305
|
+
return Principal.for_agent(token.agent, acts_for=token.handle)
|
|
306
|
+
return Principal.person(token.handle)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
#: The most exchanged tokens that the cache keeps (research R-3 of spec 013).
|
|
310
|
+
BASIC_CACHE_SIZE = 1000
|
|
311
|
+
#: A token leaves the cache this many seconds before it expires (ADR-018).
|
|
312
|
+
BASIC_MARGIN_SECONDS = 30
|
|
313
|
+
#: A refused header is kept this many seconds, so a repeat sends no request to the issuer.
|
|
314
|
+
BASIC_REFUSED_SECONDS = 10
|
|
315
|
+
#: The most exchanges at the same time. The exchanges have a thread limit of their own,
|
|
316
|
+
#: so a flood of Basic headers cannot take the threads of the token checks.
|
|
317
|
+
BASIC_THREADS = 4
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class BasicExchange:
|
|
321
|
+
"""Exchange `Authorization: Basic <user name>:<app password>` for a token (ADR-018).
|
|
322
|
+
|
|
323
|
+
curu-memory sends the credentials to the token endpoint of the issuer, with the
|
|
324
|
+
client credentials grant of Authentik. The token then gets each check of a bearer
|
|
325
|
+
token. The cache key is the SHA-256 of the full header, so curu-memory keeps no
|
|
326
|
+
password, and two callers never share a token.
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
def __init__(
|
|
330
|
+
self, *, client_id: str, keys: KeyCache, audit: AuditLog, timeout: float = 5.0
|
|
331
|
+
) -> None:
|
|
332
|
+
self._client_id = client_id
|
|
333
|
+
self._keys = keys
|
|
334
|
+
self._audit = audit
|
|
335
|
+
self._timeout = timeout
|
|
336
|
+
# A value of None is a refused header.
|
|
337
|
+
self._cache: OrderedDict[str, tuple[str | None, float]] = OrderedDict()
|
|
338
|
+
self._lock = threading.Lock()
|
|
339
|
+
|
|
340
|
+
def __repr__(self) -> str:
|
|
341
|
+
return f"BasicExchange(client_id={self._client_id!r})"
|
|
342
|
+
|
|
343
|
+
def token_for(self, header: str) -> str | None:
|
|
344
|
+
"""The access token for a Basic header, or None after one refused audit line."""
|
|
345
|
+
key = hashlib.sha256(header.encode()).hexdigest()
|
|
346
|
+
with self._lock:
|
|
347
|
+
cached = self._cache.get(key)
|
|
348
|
+
if cached is not None and cached[1] > time.monotonic():
|
|
349
|
+
self._cache.move_to_end(key)
|
|
350
|
+
return cached[0]
|
|
351
|
+
try:
|
|
352
|
+
user, password = _credentials(header)
|
|
353
|
+
token, lifetime = self._exchange(user, password)
|
|
354
|
+
except (httpx2.HTTPError, KeyError, ValueError, TypeError):
|
|
355
|
+
# The reason holds no user name and no password (FR-016).
|
|
356
|
+
self._audit.write("anonymous", "token", None, REFUSED, "basic exchange refused")
|
|
357
|
+
self._keep(key, None, BASIC_REFUSED_SECONDS)
|
|
358
|
+
return None
|
|
359
|
+
self._keep(
|
|
360
|
+
key, token, min(lifetime, _seconds_to_exp(token), MAX_LIFETIME) - BASIC_MARGIN_SECONDS
|
|
361
|
+
)
|
|
362
|
+
return token
|
|
363
|
+
|
|
364
|
+
def _keep(self, key: str, token: str | None, seconds: float) -> None:
|
|
365
|
+
if seconds <= 0:
|
|
366
|
+
return
|
|
367
|
+
with self._lock:
|
|
368
|
+
self._cache[key] = (token, time.monotonic() + seconds)
|
|
369
|
+
self._cache.move_to_end(key)
|
|
370
|
+
while len(self._cache) > BASIC_CACHE_SIZE:
|
|
371
|
+
self._cache.popitem(last=False)
|
|
372
|
+
|
|
373
|
+
def _exchange(self, user: str, password: str) -> tuple[str, float]:
|
|
374
|
+
endpoint = _safe_url(str(self._keys.discovery()["token_endpoint"]))
|
|
375
|
+
response = httpx2.post(
|
|
376
|
+
endpoint,
|
|
377
|
+
data={
|
|
378
|
+
"grant_type": "client_credentials",
|
|
379
|
+
"client_id": self._client_id,
|
|
380
|
+
"username": user,
|
|
381
|
+
"password": password,
|
|
382
|
+
"scope": "openid profile",
|
|
383
|
+
},
|
|
384
|
+
timeout=self._timeout,
|
|
385
|
+
follow_redirects=False,
|
|
386
|
+
)
|
|
387
|
+
response.raise_for_status()
|
|
388
|
+
body = response.json()
|
|
389
|
+
return str(body["access_token"]), float(body.get("expires_in", 300))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _seconds_to_exp(token: str) -> float:
|
|
393
|
+
"""The seconds to the `exp` of a token, read with no check: only the cache uses it."""
|
|
394
|
+
try:
|
|
395
|
+
exp = jwt.decode(token, options={"verify_signature": False}).get("exp")
|
|
396
|
+
return float(str(exp)) - time.time()
|
|
397
|
+
except (jwt.PyJWTError, TypeError, ValueError):
|
|
398
|
+
return 0.0
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _credentials(header: str) -> tuple[str, str]:
|
|
402
|
+
scheme, _, value = header.partition(" ")
|
|
403
|
+
if scheme.lower() != "basic":
|
|
404
|
+
raise ValueError("not a Basic header")
|
|
405
|
+
user, separator, password = (
|
|
406
|
+
base64.b64decode(value.strip(), validate=True).decode().partition(":")
|
|
407
|
+
)
|
|
408
|
+
if not separator or not user or not password:
|
|
409
|
+
raise ValueError("a Basic header needs a user name and a password")
|
|
410
|
+
return user, password
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class BasicToBearer:
|
|
414
|
+
"""An ASGI middleware: it replaces a Basic header with the token of the exchange.
|
|
415
|
+
|
|
416
|
+
It runs before the authentication of the SDK, which reads only Bearer tokens. A
|
|
417
|
+
refused exchange keeps the Basic header, so the SDK answers HTTP 401.
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
def __init__(self, app: Any, exchange: BasicExchange) -> None:
|
|
421
|
+
self._app = app
|
|
422
|
+
self._exchange = exchange
|
|
423
|
+
self._threads = anyio.CapacityLimiter(BASIC_THREADS)
|
|
424
|
+
|
|
425
|
+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
|
426
|
+
if scope.get("type") == "http":
|
|
427
|
+
headers = list(scope.get("headers", []))
|
|
428
|
+
for index, (name, value) in enumerate(headers):
|
|
429
|
+
if name == b"authorization" and value[:6].lower() == b"basic ":
|
|
430
|
+
token = await anyio.to_thread.run_sync(
|
|
431
|
+
self._exchange.token_for, value.decode("latin-1"), limiter=self._threads
|
|
432
|
+
)
|
|
433
|
+
if token is not None:
|
|
434
|
+
headers[index] = (name, b"Bearer " + token.encode())
|
|
435
|
+
scope = {**scope, "headers": headers}
|
|
436
|
+
break
|
|
437
|
+
await self._app(scope, receive, send)
|