fastapi-multiauth 1.1.0__tar.gz → 1.2.0__tar.gz

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.
Files changed (31) hide show
  1. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/PKG-INFO +4 -4
  2. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/README.md +2 -2
  3. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/pyproject.toml +2 -2
  4. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/pyproject.toml.orig +2 -2
  5. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/__init__.py +1 -1
  6. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/abc.py +65 -40
  7. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/introspection.py +16 -62
  8. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/jwt.py +83 -28
  9. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/oauth/client.py +43 -5
  10. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/oauth/id_token.py +5 -2
  11. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/oauth/utils.py +3 -0
  12. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/basic.py +2 -0
  13. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/bearer.py +11 -3
  14. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/cookie.py +3 -11
  15. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/dpop.py +16 -9
  16. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/header.py +2 -0
  17. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/multi.py +44 -15
  18. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/signature.py +2 -0
  19. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/surface.py +78 -40
  20. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/utils.py +145 -9
  21. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/LICENSE +0 -0
  22. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/_http.py +0 -0
  23. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/_imports.py +0 -0
  24. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/amr.py +0 -0
  25. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/exceptions.py +0 -0
  26. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/oauth/__init__.py +0 -0
  27. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/py.typed +0 -0
  28. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/__init__.py +0 -0
  29. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/oauth2.py +0 -0
  30. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/query.py +0 -0
  31. {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/totp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-multiauth
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Composable authentication sources for FastAPI: bring your own validator, combine multiple schemes, correct OpenAPI by construction
5
5
  Keywords: fastapi,authentication,security,oauth,api-key,bearer,cookie
6
6
  Author: d3vyce
@@ -18,13 +18,13 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: 3.14
20
20
  Classifier: Programming Language :: Python :: 3.15
21
+ Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
21
22
  Classifier: Topic :: Internet :: WWW/HTTP :: Session
22
23
  Classifier: Topic :: Security
23
24
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
25
  Classifier: Topic :: Software Development :: Libraries
25
26
  Classifier: Typing :: Typed
26
27
  Requires-Dist: anyio>=3.7
27
- Requires-Dist: async-lru>=2.0
28
28
  Requires-Dist: fastapi>=0.123.0,!=0.137.0,!=0.137.1
29
29
  Requires-Dist: httpx>=0.25.0
30
30
  Requires-Dist: itsdangerous>=2.1
@@ -124,12 +124,12 @@ Set `SECRET_KEY` to at least 32 random bytes, for example the output of `python
124
124
  - **Every standard scheme**: bearer tokens with Stripe-style prefixes, signed cookie sessions, API keys, HTTP Basic, OAuth 2.0 and OpenID Connect.
125
125
  - **`MultiAuth`**: several schemes on one route, all documented in OpenAPI, plus `optional()` for routes that also serve anonymous callers.
126
126
  - **Stateless sessions**: HMAC-signed cookies with expiry, key rotation, opt-in revocable session ids, and cross-origin requests refused by default.
127
- - **Tokens done right**: secure generation, hash-only storage, checksums, `JWTValidator` for provider JWTs (JWKS, rotation, `aud`/`iss`), `IntrospectionValidator` for opaque ones, and `HTTPDPoPAuth` for tokens bound to the client's key.
127
+ - **Tokens done right**: secure generation, hash-only storage, checksums, `JWTValidator` for provider JWTs (JWKS, rotation, `aud`/`iss`/`typ`), `IntrospectionValidator` for opaque ones, and `HTTPDPoPAuth` for tokens bound to the client's key.
128
128
  - **Signed webhooks**: `HMACSignatureAuth` checks the sender's signature over the raw body, with timestamp tolerance and secret rotation.
129
129
  - **Scopes and step-up**: fail-closed scope checks, and `fresh()` / `stepped_up()` with RFC 9470 challenges for sensitive routes.
130
130
  - **Second factor**: TOTP helpers that refuse a replayed code, and the login level signed into the session cookie.
131
131
  - **OAuth login helpers**: discovery, CSRF `state`, PKCE, code exchange and ID token validation.
132
- - **Observable**: reason-coded security logs and `auth_surface(app)` to audit what guards every route.
132
+ - **Observable**: reason-coded security logs and `auth_surface(app)` to audit what guards every route, `dependency_overrides` included.
133
133
 
134
134
  See the [comparison](https://fastapi-multiauth.d3vyce.fr/guide/comparison/) for how it sits next to `fastapi.security`, fastapi-users, AuthX and Authlib, and the [security model](https://fastapi-multiauth.d3vyce.fr/security-model/) for what it guarantees.
135
135
 
@@ -83,12 +83,12 @@ Set `SECRET_KEY` to at least 32 random bytes, for example the output of `python
83
83
  - **Every standard scheme**: bearer tokens with Stripe-style prefixes, signed cookie sessions, API keys, HTTP Basic, OAuth 2.0 and OpenID Connect.
84
84
  - **`MultiAuth`**: several schemes on one route, all documented in OpenAPI, plus `optional()` for routes that also serve anonymous callers.
85
85
  - **Stateless sessions**: HMAC-signed cookies with expiry, key rotation, opt-in revocable session ids, and cross-origin requests refused by default.
86
- - **Tokens done right**: secure generation, hash-only storage, checksums, `JWTValidator` for provider JWTs (JWKS, rotation, `aud`/`iss`), `IntrospectionValidator` for opaque ones, and `HTTPDPoPAuth` for tokens bound to the client's key.
86
+ - **Tokens done right**: secure generation, hash-only storage, checksums, `JWTValidator` for provider JWTs (JWKS, rotation, `aud`/`iss`/`typ`), `IntrospectionValidator` for opaque ones, and `HTTPDPoPAuth` for tokens bound to the client's key.
87
87
  - **Signed webhooks**: `HMACSignatureAuth` checks the sender's signature over the raw body, with timestamp tolerance and secret rotation.
88
88
  - **Scopes and step-up**: fail-closed scope checks, and `fresh()` / `stepped_up()` with RFC 9470 challenges for sensitive routes.
89
89
  - **Second factor**: TOTP helpers that refuse a replayed code, and the login level signed into the session cookie.
90
90
  - **OAuth login helpers**: discovery, CSRF `state`, PKCE, code exchange and ID token validation.
91
- - **Observable**: reason-coded security logs and `auth_surface(app)` to audit what guards every route.
91
+ - **Observable**: reason-coded security logs and `auth_surface(app)` to audit what guards every route, `dependency_overrides` included.
92
92
 
93
93
  See the [comparison](https://fastapi-multiauth.d3vyce.fr/guide/comparison/) for how it sits next to `fastapi.security`, fastapi-users, AuthX and Authlib, and the [security model](https://fastapi-multiauth.d3vyce.fr/security-model/) for what it guarantees.
94
94
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-multiauth"
3
- version = "1.1.0"
3
+ version = "1.2.0"
4
4
  description = "Composable authentication sources for FastAPI: bring your own validator, combine multiple schemes, correct OpenAPI by construction"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -27,6 +27,7 @@ classifiers = [
27
27
  "Programming Language :: Python :: 3.13",
28
28
  "Programming Language :: Python :: 3.14",
29
29
  "Programming Language :: Python :: 3.15",
30
+ "Programming Language :: Python :: Free Threading :: 3 - Stable",
30
31
  "Topic :: Internet :: WWW/HTTP :: Session",
31
32
  "Topic :: Security",
32
33
  "Topic :: Software Development :: Libraries :: Python Modules",
@@ -35,7 +36,6 @@ classifiers = [
35
36
  ]
36
37
  dependencies = [
37
38
  "anyio>=3.7",
38
- "async-lru>=2.0",
39
39
  "fastapi>=0.123.0,!=0.137.0,!=0.137.1",
40
40
  "httpx>=0.25.0",
41
41
  "itsdangerous>=2.1",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-multiauth"
3
- version = "1.1.0"
3
+ version = "1.2.0"
4
4
  description = "Composable authentication sources for FastAPI: bring your own validator, combine multiple schemes, correct OpenAPI by construction"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -22,6 +22,7 @@ classifiers = [
22
22
  "Programming Language :: Python :: 3.13",
23
23
  "Programming Language :: Python :: 3.14",
24
24
  "Programming Language :: Python :: 3.15",
25
+ "Programming Language :: Python :: Free Threading :: 3 - Stable",
25
26
  "Topic :: Internet :: WWW/HTTP :: Session",
26
27
  "Topic :: Security",
27
28
  "Topic :: Software Development :: Libraries :: Python Modules",
@@ -30,7 +31,6 @@ classifiers = [
30
31
  ]
31
32
  dependencies = [
32
33
  "anyio>=3.7",
33
- "async-lru>=2.0",
34
34
  "fastapi>=0.123.0,!=0.137.0,!=0.137.1",
35
35
  "httpx>=0.25.0",
36
36
  "itsdangerous>=2.1",
@@ -43,7 +43,7 @@ from .utils import (
43
43
  verify_token_hash,
44
44
  )
45
45
 
46
- __version__ = "1.1.0"
46
+ __version__ = "1.2.0"
47
47
 
48
48
  logging.getLogger(__name__).addHandler(logging.NullHandler())
49
49
 
@@ -6,7 +6,7 @@ import logging
6
6
  from abc import ABC, abstractmethod
7
7
  from collections.abc import Awaitable, Callable, Sequence
8
8
  from dataclasses import dataclass
9
- from typing import Any, ClassVar, Self
9
+ from typing import Any, ClassVar, NamedTuple, Self
10
10
 
11
11
  from fastapi import HTTPException
12
12
  from fastapi.requests import HTTPConnection
@@ -208,10 +208,13 @@ def _carry_scheme(dependency: Any, scheme: SecurityBase) -> None:
208
208
  cls = type(dependency)
209
209
  promoted = _SCHEME_CLASSES.get(cls)
210
210
  if promoted is None:
211
- promoted = _SCHEME_CLASSES[cls] = type(
212
- cls.__name__,
213
- (cls, SecurityBase),
214
- {"__module__": cls.__module__, "__doc__": cls.__doc__},
211
+ promoted = _SCHEME_CLASSES.setdefault(
212
+ cls,
213
+ type(
214
+ cls.__name__,
215
+ (cls, SecurityBase),
216
+ {"__module__": cls.__module__, "__doc__": cls.__doc__},
217
+ ),
215
218
  )
216
219
  dependency.__class__ = promoted
217
220
  dependency.model = scheme.model
@@ -228,6 +231,28 @@ class _Freshness:
228
231
  """Caller-supplied reader of the instant, `None` to use the source's own."""
229
232
 
230
233
 
234
+ async def _within(freshness: _Freshness, instant: Any, identity: Any) -> bool:
235
+ """Whether a credential proven at `instant` falls inside `freshness`.
236
+
237
+ `freshness.read`, when set, reads the instant off `identity` instead.
238
+ """
239
+ if freshness.read is not None:
240
+ instant = await freshness.read(identity)
241
+ age = credential_age(instant)
242
+ return age is not None and (
243
+ -freshness.leeway <= age <= freshness.max_age + freshness.leeway
244
+ )
245
+
246
+
247
+ class _Record(NamedTuple):
248
+ """What a source leaves in the request scope once it authenticated it."""
249
+
250
+ source: "AuthSource"
251
+ authenticated_at: Any
252
+ """When the credential was proven, so `ensure_fresh()` never sees the credential."""
253
+ identity: Any
254
+
255
+
231
256
  @dataclass(frozen=True, slots=True)
232
257
  class _Assurance:
233
258
  """The levels set by `stepped_up()`."""
@@ -243,7 +268,8 @@ class AuthSource(ABC):
243
268
  Subclass it to read a credential the built-in sources do not handle.
244
269
  Implement `extract()` and `authenticate()`; override `www_authenticate()`,
245
270
  `authenticated_at()`, `authenticated_acr()` or `authenticate_scoped()`
246
- when the source needs them.
271
+ when the source needs them, and set `authorization_scheme` on a source
272
+ that reads the `Authorization` header.
247
273
 
248
274
  Args:
249
275
  scheme: A `fastapi.security` scheme describing the source in OpenAPI.
@@ -251,6 +277,11 @@ class AuthSource(ABC):
251
277
  """
252
278
 
253
279
  scheme: SecurityBase | None
280
+ authorization_scheme: str | None = None
281
+ """The `Authorization` scheme this source reads, lowercased: `"*"` for any
282
+ scheme, `None` when it does not read the header. Under `MultiAuth`, a
283
+ header of a scheme a source reads, which no such source takes, is refused
284
+ instead of passing to a cookie or an API key."""
254
285
  _optional: bool = False
255
286
  _scope_catalogue: dict[str, str] | None = None
256
287
  """The scopes this source publishes to OpenAPI, `None` when it has none."""
@@ -346,14 +377,22 @@ class AuthSource(ABC):
346
377
  ) -> Any:
347
378
  """Authenticate, attaching this source's challenge to any 401 raised.
348
379
 
349
- Freshness is checked after the credential: an invalid one is refused as
350
- invalid, not as stale. Every outcome is logged.
380
+ A validator that returns `None` or `False` refuses the credential: a
381
+ lookup that found nothing, or a comparison that failed, must never
382
+ pass for an identity. Freshness is checked after the credential: an
383
+ invalid one is refused as invalid, not as stale. Every outcome is logged.
384
+
385
+ What is recorded for `ensure_fresh()` is when the credential was
386
+ proven, not the credential itself, so the record never holds it.
351
387
  """
352
388
  try:
353
389
  identity = await self.authenticate_scoped(
354
390
  credential, scopes, request=request
355
391
  )
356
- await self._enforce_step_up(credential, identity, request)
392
+ if identity is None or identity is False:
393
+ raise refusal(UnauthorizedError(), "no_identity")
394
+ instant = self._credential_authenticated_at(credential, request)
395
+ await self._enforce_step_up(credential, identity, request, instant)
357
396
  except HTTPException as exc:
358
397
  add_challenge(exc, self.www_authenticate())
359
398
  self._log(logging.WARNING, refusal_reason(exc), exc.status_code)
@@ -361,7 +400,7 @@ class AuthSource(ABC):
361
400
  records = request.scope.get(_AUTHENTICATED)
362
401
  if records is None:
363
402
  records = request.scope[_AUTHENTICATED] = {}
364
- records[self._record_key()] = (self, credential, identity)
403
+ records[self._record_key()] = _Record(self, instant, identity)
365
404
  if self._logger.isEnabledFor(logging.DEBUG):
366
405
  self._log(logging.DEBUG, "authenticated")
367
406
  return identity
@@ -401,7 +440,9 @@ class AuthSource(ABC):
401
440
  """Return when a credential was proven, for `fresh()`.
402
441
 
403
442
  Override it in a source that can date its own credentials, as the
404
- signed cookie does.
443
+ signed cookie does. It runs on every authenticated request, whether
444
+ or not the route asks for freshness, since `ensure_fresh()` may ask
445
+ later: return `None` rather than raise for a credential it cannot date.
405
446
 
406
447
  Args:
407
448
  credential: The credential returned by `extract()`.
@@ -537,15 +578,11 @@ class AuthSource(ABC):
537
578
  await self._check_record(request, record, window)
538
579
 
539
580
  async def _check_record(
540
- self,
541
- request: HTTPConnection,
542
- record: tuple["AuthSource", str, Any],
543
- window: _Freshness,
581
+ self, request: HTTPConnection, record: _Record, window: _Freshness
544
582
  ) -> None:
545
583
  """Refuse the recorded authentication when it falls outside `window`."""
546
- source, credential, identity = record
547
- if not await self._is_fresh(window, credential, identity, request):
548
- assurance = source._assurance
584
+ if not await _within(window, record.authenticated_at, record.identity):
585
+ assurance = record.source._assurance
549
586
  exc = refusal(
550
587
  self._challenge_error(
551
588
  StaleCredentialError,
@@ -622,13 +659,18 @@ class AuthSource(ABC):
622
659
  return clone
623
660
 
624
661
  async def _enforce_step_up(
625
- self, credential: str, identity: Any, request: HTTPConnection | None = None
662
+ self,
663
+ credential: str,
664
+ identity: Any,
665
+ request: HTTPConnection | None,
666
+ instant: Any,
626
667
  ) -> None:
627
- """Refuse an identity outside the `fresh()` window or `stepped_up()` levels."""
668
+ """Refuse an identity outside the `fresh()` window or `stepped_up()` levels.
669
+
670
+ `instant` is what `_credential_authenticated_at()` read for the credential.
671
+ """
628
672
  freshness, assurance = self._freshness, self._assurance
629
- if freshness is not None and not await self._is_fresh(
630
- freshness, credential, identity, request
631
- ):
673
+ if freshness is not None and not await _within(freshness, instant, identity):
632
674
  raise refusal(self._step_up_error(StaleCredentialError), "stale")
633
675
  if assurance is not None:
634
676
  if assurance.read is not None:
@@ -641,23 +683,6 @@ class AuthSource(ABC):
641
683
  "insufficient_acr",
642
684
  )
643
685
 
644
- async def _is_fresh(
645
- self,
646
- freshness: _Freshness,
647
- credential: str,
648
- identity: Any,
649
- request: HTTPConnection | None,
650
- ) -> bool:
651
- """Whether the credential was proven inside the window."""
652
- if freshness.read is not None:
653
- instant = await freshness.read(identity)
654
- else:
655
- instant = self._credential_authenticated_at(credential, request)
656
- age = credential_age(instant)
657
- return age is not None and (
658
- -freshness.leeway <= age <= freshness.max_age + freshness.leeway
659
- )
660
-
661
686
  def _credential_authenticated_at(
662
687
  self, credential: str, request: HTTPConnection | None
663
688
  ) -> Any:
@@ -4,23 +4,24 @@ import base64
4
4
  import copy
5
5
  import logging
6
6
  import time
7
- from collections import OrderedDict
8
7
  from collections.abc import Callable, Sequence
9
8
  from typing import Any, ClassVar
10
9
  from urllib.parse import quote_plus
11
10
 
12
- import anyio
13
11
  import httpx
14
12
 
15
13
  from ._http import DEFAULT_TIMEOUT, _request_json, _require_https
16
14
  from .exceptions import UnauthorizedError
17
15
  from .jwt import AnyAudience
18
16
  from .utils import (
17
+ SingleFlight,
18
+ TTLCache,
19
19
  ensure_async,
20
20
  finite_number,
21
21
  hash_token,
22
22
  record_verified_jkt,
23
23
  refusal,
24
+ reject_other_token_kinds,
24
25
  require_nonnegative_seconds,
25
26
  require_scopes,
26
27
  service_unavailable,
@@ -28,11 +29,6 @@ from .utils import (
28
29
 
29
30
  logger = logging.getLogger(__name__)
30
31
 
31
- _OTHER_TOKEN_KINDS = {
32
- "typ": frozenset({"refresh", "offline", "id", "logout"}),
33
- "token_use": frozenset({"refresh_token", "id_token", "refresh", "id"}),
34
- }
35
-
36
32
 
37
33
  class _IntrospectionUnavailable(Exception):
38
34
  """The introspection endpoint is unreachable or answered unusably."""
@@ -57,15 +53,6 @@ class _Answer:
57
53
  return copied
58
54
 
59
55
 
60
- class _Lookup:
61
- """One introspection in flight, awaited by every request carrying its token."""
62
-
63
- def __init__(self) -> None:
64
- self.done = anyio.Event()
65
- self.answer: _Answer | None = None
66
- self.error: Exception | None = None
67
-
68
-
69
56
  def _basic_authorization(client_id: str, client_secret: str) -> str:
70
57
  """`client_secret_basic`: both halves form-encoded before base64 (RFC 6749 §2.3.1)."""
71
58
  pair = f"{quote_plus(client_id, safe='')}:{quote_plus(client_secret, safe='')}"
@@ -157,9 +144,8 @@ class IntrospectionValidator:
157
144
  ensure_async(claims_to_identity) if claims_to_identity else None
158
145
  )
159
146
  self._cache_ttl = cache_ttl
160
- self._cache_size = cache_size
161
- self._cache: OrderedDict[str, tuple[float, _Answer]] = OrderedDict()
162
- self._lookups: dict[str, _Lookup] = {}
147
+ self._cache = TTLCache(cache_size)
148
+ self._lookups = SingleFlight()
163
149
  self._timeout = timeout
164
150
  self._client = client
165
151
 
@@ -172,7 +158,9 @@ class IntrospectionValidator:
172
158
  HTTPException: 503 if the introspection endpoint cannot be used.
173
159
  """
174
160
  key = hash_token(token)
175
- answer = self._cached(key) or await self._lookup(token, key)
161
+ answer = self._cache.get(key) or await self._lookups.run(
162
+ key, lambda: self._lookup(token, key)
163
+ )
176
164
  claims = answer.copy()
177
165
  if scopes:
178
166
  require_scopes(claims, self._scopes_claim, scopes)
@@ -182,26 +170,11 @@ class IntrospectionValidator:
182
170
  return claims
183
171
 
184
172
  async def _lookup(self, token: str, key: str) -> _Answer:
185
- """The answer for `token`, from the introspection in flight or a new one."""
186
- while (lookup := self._lookups.get(key)) is not None:
187
- await lookup.done.wait()
188
- if lookup.error is not None:
189
- raise copy.copy(lookup.error)
190
- if lookup.answer is not None:
191
- return lookup.answer
192
- lookup = self._lookups[key] = _Lookup()
193
- try:
194
- claims = await self._introspect(token)
195
- self._check_claims(claims)
196
- answer = _Answer(claims)
197
- self._store(key, answer)
198
- lookup.answer = answer
199
- except Exception as exc:
200
- lookup.error = exc
201
- raise
202
- finally:
203
- del self._lookups[key]
204
- lookup.done.set()
173
+ """Introspect `token`, check its claims and cache the answer."""
174
+ claims = await self._introspect(token)
175
+ self._check_claims(claims)
176
+ answer = _Answer(claims)
177
+ self._store(key, answer)
205
178
  return answer
206
179
 
207
180
  async def _introspect(self, token: str) -> dict[str, Any]:
@@ -238,10 +211,7 @@ class IntrospectionValidator:
238
211
 
239
212
  def _check_claims(self, claims: dict[str, Any]) -> None:
240
213
  """Check the token is an access token, then `exp`, `nbf`, `aud` and `iss`."""
241
- for field, kinds in _OTHER_TOKEN_KINDS.items():
242
- kind = claims.get(field)
243
- if isinstance(kind, str) and kind.lower() in kinds:
244
- raise refusal(UnauthorizedError(), "not_access_token")
214
+ reject_other_token_kinds(claims)
245
215
  now = time.time()
246
216
  if "exp" in claims:
247
217
  exp = finite_number(claims["exp"])
@@ -265,27 +235,11 @@ class IntrospectionValidator:
265
235
  if self._issuer is not None and claims.get("iss") != self._issuer:
266
236
  raise refusal(UnauthorizedError(), "invalid_issuer")
267
237
 
268
- def _cached(self, key: str) -> _Answer | None:
269
- """A cached answer still within its lifetime, `None` otherwise."""
270
- entry = self._cache.get(key)
271
- if entry is None:
272
- return None
273
- expires_at, answer = entry
274
- if time.monotonic() >= expires_at:
275
- del self._cache[key]
276
- return None
277
- self._cache.move_to_end(key)
278
- return answer
279
-
280
238
  def _store(self, key: str, answer: _Answer) -> None:
281
239
  """Cache an active answer for `cache_ttl`, never past the token's `exp`."""
282
240
  ttl = self._cache_ttl
283
241
  exp = finite_number(answer.claims.get("exp"))
284
242
  if exp is not None:
285
243
  ttl = min(ttl, exp - time.time())
286
- if ttl <= 0:
287
- return
288
- self._cache[key] = (time.monotonic() + ttl, answer)
289
- self._cache.move_to_end(key)
290
- while len(self._cache) > self._cache_size:
291
- self._cache.popitem(last=False)
244
+ if ttl > 0:
245
+ self._cache.put(key, answer, ttl)
@@ -2,19 +2,22 @@
2
2
 
3
3
  import enum
4
4
  import logging
5
+ import threading
5
6
  import time
6
7
  from collections.abc import Callable, Sequence
7
8
  from typing import Any, ClassVar
8
9
 
9
- import anyio
10
+ from anyio.lowlevel import current_token
10
11
 
11
12
  from ._http import DEFAULT_TIMEOUT, _get_json, _require_https
12
13
  from ._imports import require_extra
13
14
  from .exceptions import UnauthorizedError
14
15
  from .utils import (
16
+ SingleFlight,
15
17
  ensure_async,
16
18
  record_verified_jkt,
17
19
  refusal,
20
+ reject_other_token_kinds,
18
21
  require_nonnegative_seconds,
19
22
  require_scopes,
20
23
  service_unavailable,
@@ -65,6 +68,11 @@ def _reject_symmetric(algorithms: Sequence[str], url_param: str) -> None:
65
68
  )
66
69
 
67
70
 
71
+ def _normalize_typ(typ: str) -> str:
72
+ """A JWT header `typ`, lowercased and without the optional `application/`."""
73
+ return typ.lower().removeprefix("application/")
74
+
75
+
68
76
  def _import_pyjwt() -> Any:
69
77
  try:
70
78
  import jwt as pyjwt
@@ -112,9 +120,10 @@ class _JWKSKeySet:
112
120
  self._refresh_cooldown = refresh_cooldown
113
121
  self._timeout = timeout
114
122
  self._keys: dict[str, Any] = {}
115
- self._fetched_at = 0.0
116
- self._attempted_at = float("-inf")
117
- self._refresh_lock = anyio.Lock()
123
+ self._fetched_at = float("-inf")
124
+ self._attempts: dict[Any, float] = {}
125
+ self._lock = threading.Lock()
126
+ self._refreshes = SingleFlight()
118
127
 
119
128
  async def key_for_token(self, token: str) -> Any:
120
129
  """The key that signed `token`, found by its header `kid`.
@@ -153,27 +162,42 @@ class _JWKSKeySet:
153
162
  async def _refresh(self, force: bool = False) -> None:
154
163
  """Refetch the JWKS, coalescing concurrent and repeated refreshes.
155
164
 
156
- At most one attempt per `refresh_cooldown`; a failed fetch falls
165
+ Requests on one event loop share its refresh in flight. Each loop
166
+ attempts at most one fetch per `refresh_cooldown`, and none within
167
+ it of a fetch that succeeded on any loop; a failed fetch falls
157
168
  back on cached keys, or raises when the cache is empty.
158
169
  """
159
- async with self._refresh_lock:
160
- # Re-check after acquiring: another request may just have tried.
161
- if time.monotonic() - self._attempted_at < self._refresh_cooldown:
162
- if not self._keys:
163
- raise _JWKSUnavailable()
164
- return
165
- if not force and not self._cache_stale():
166
- return
167
- self._attempted_at = time.monotonic()
168
- try:
169
- await self._fetch()
170
- except _JWKSFetchError as exc:
171
- logger.warning("JWKS refresh failed: %s", exc)
172
- if not self._keys:
173
- raise _JWKSUnavailable() from exc
174
- # Stale fallback: keep validating with the cached keys.
170
+ loop = current_token()
171
+ await self._refreshes.run(None, lambda: self._attempt(loop, force))
172
+
173
+ async def _attempt(self, loop: Any, force: bool) -> None:
174
+ if not self._claim_attempt(loop, force):
175
+ if not self._keys:
176
+ raise _JWKSUnavailable()
177
+ return
178
+ try:
179
+ await self._fetch()
180
+ except _JWKSFetchError as exc:
181
+ logger.warning("JWKS refresh failed: %s", exc)
182
+ if not self._keys:
183
+ raise _JWKSUnavailable() from exc
184
+
185
+ def _claim_attempt(self, loop: Any, force: bool) -> bool:
186
+ """Whether `loop` may fetch now, recording the attempt when it may."""
187
+ now = time.monotonic()
188
+ cooldown = self._refresh_cooldown
189
+ with self._lock:
190
+ last = max(self._fetched_at, self._attempts.get(loop, float("-inf")))
191
+ if now - last < cooldown or not (force or self._cache_stale()):
192
+ return False
193
+ self._attempts = {
194
+ seen: at for seen, at in self._attempts.items() if now - at < cooldown
195
+ }
196
+ self._attempts[loop] = now
197
+ return True
175
198
 
176
199
  async def _fetch(self) -> None:
200
+ started = time.monotonic()
177
201
  document = await _get_json(
178
202
  self._url,
179
203
  timeout=self._timeout,
@@ -215,8 +239,10 @@ class _JWKSKeySet:
215
239
  f"JWKS document has no usable signing key "
216
240
  f"({len(document['keys'])} entries)"
217
241
  )
218
- self._keys = keys
219
- self._fetched_at = time.monotonic()
242
+ with self._lock:
243
+ if started >= self._fetched_at:
244
+ self._keys = keys
245
+ self._fetched_at = started
220
246
 
221
247
 
222
248
  class JWTValidator:
@@ -227,17 +253,28 @@ class JWTValidator:
227
253
  cached and refreshed when the provider rotates them; if the provider
228
254
  is down, cached keys keep working, and a cold start answers 503.
229
255
 
256
+ A token whose `typ` or `token_use` claim marks it as a refresh, ID or
257
+ logout token is refused (`not_access_token`), as the introspection
258
+ validator does. Providers that mark their access tokens in the header
259
+ can be held to it with `typ`.
260
+
230
261
  Args:
231
262
  secret: Shared HMAC key of at least 32 bytes.
232
263
  jwks_url: HTTPS URL of the provider's public keys.
233
264
  algorithms: Accepted algorithms; `["HS256"]` with a secret,
234
265
  `["RS256", "ES256"]` with a JWKS, where `HS*` is refused.
235
- audience: Expected `aud`. Required with a JWKS, since a provider
236
- signs every application's tokens with the same keys; pass
237
- `ANY_AUDIENCE` to skip the check on purpose.
266
+ audience: Expected `aud`: your API's own identifier. Required with
267
+ a JWKS, since a provider signs every application's tokens with
268
+ the same keys; pass `ANY_AUDIENCE` to skip the check on purpose.
269
+ Do not use the client id of a front-end here: the ID tokens the
270
+ provider hands that front-end carry it as `aud` too.
238
271
  issuer: Expected `iss`; not checked when unset.
239
272
  leeway: Clock-skew tolerance in seconds for `exp`, `nbf` and `iat`.
240
273
  required_claims: Claims every token must carry.
274
+ typ: Expected JWT header `typ`, e.g. `"at+jwt"` for a provider
275
+ issuing [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068)
276
+ access tokens. Compared case-insensitively, with or without the
277
+ `application/` prefix. Not checked when unset.
241
278
  scopes_claim: Claim holding granted scopes: a space-separated string
242
279
  (`scope`) or a list (`scp`, `roles`). A missing route scope is a 403.
243
280
  claims_to_identity: Sync or async callable turning the verified
@@ -249,7 +286,7 @@ class JWTValidator:
249
286
  Raises:
250
287
  ValueError: On an invalid configuration, such as both or neither of
251
288
  `secret` and `jwks_url`, a short secret, a JWKS without `audience`,
252
- or a negative `leeway`.
289
+ a negative `leeway`, or an empty `typ`.
253
290
  """
254
291
 
255
292
  ANY_AUDIENCE: ClassVar[AnyAudience] = AnyAudience.ANY
@@ -265,6 +302,7 @@ class JWTValidator:
265
302
  issuer: str | None = None,
266
303
  leeway: float = 0.0,
267
304
  required_claims: Sequence[str] = ("exp",),
305
+ typ: str | None = None,
268
306
  scopes_claim: str = "scope",
269
307
  claims_to_identity: Callable[[dict[str, Any]], Any] | None = None,
270
308
  jwks_cache_ttl: float = 3600.0,
@@ -275,6 +313,11 @@ class JWTValidator:
275
313
  if (secret is None) == (jwks_url is None):
276
314
  raise ValueError("provide exactly one of 'secret' or 'jwks_url'")
277
315
  require_nonnegative_seconds(leeway, "leeway")
316
+ if typ is not None:
317
+ if not typ:
318
+ raise ValueError("typ must be a non-empty string")
319
+ typ = _normalize_typ(typ)
320
+ self._typ = typ
278
321
  if secret is not None:
279
322
  if len(secret.encode()) < _MIN_SECRET_BYTES:
280
323
  raise ValueError(
@@ -343,7 +386,7 @@ class JWTValidator:
343
386
  else:
344
387
  key = await self._resolve_jwks_key(self._key_set, token)
345
388
  try:
346
- claims = self._jwt.decode(
389
+ decoded = self._jwt.decode_complete(
347
390
  token,
348
391
  key=key,
349
392
  algorithms=self._algorithms,
@@ -354,6 +397,8 @@ class JWTValidator:
354
397
  )
355
398
  except (self._jwt.exceptions.PyJWTError, TypeError, ValueError) as exc:
356
399
  raise refusal(UnauthorizedError(), self._decode_reason(exc)) from None
400
+ claims = decoded["payload"]
401
+ self._reject_other_kinds(decoded["header"], claims)
357
402
  if scopes:
358
403
  require_scopes(claims, self._scopes_claim, scopes)
359
404
  record_verified_jkt(claims)
@@ -361,6 +406,16 @@ class JWTValidator:
361
406
  return await self._claims_to_identity(claims)
362
407
  return claims
363
408
 
409
+ def _reject_other_kinds(
410
+ self, header: dict[str, Any], claims: dict[str, Any]
411
+ ) -> None:
412
+ """Refuse a verified token that is not an access token."""
413
+ if self._typ is not None:
414
+ typ = header.get("typ")
415
+ if not isinstance(typ, str) or _normalize_typ(typ) != self._typ:
416
+ raise refusal(UnauthorizedError(), "not_access_token")
417
+ reject_other_token_kinds(claims)
418
+
364
419
  def _decode_reason(self, exc: Exception) -> str:
365
420
  """The reason code of a failed decode, by PyJWT exception class."""
366
421
  for error_cls, reason in self._decode_reasons: