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/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)