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.
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/PKG-INFO +4 -4
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/README.md +2 -2
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/pyproject.toml +2 -2
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/pyproject.toml.orig +2 -2
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/__init__.py +1 -1
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/abc.py +65 -40
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/introspection.py +16 -62
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/jwt.py +83 -28
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/oauth/client.py +43 -5
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/oauth/id_token.py +5 -2
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/oauth/utils.py +3 -0
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/basic.py +2 -0
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/bearer.py +11 -3
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/cookie.py +3 -11
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/dpop.py +16 -9
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/header.py +2 -0
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/multi.py +44 -15
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/signature.py +2 -0
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/surface.py +78 -40
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/utils.py +145 -9
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/LICENSE +0 -0
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/_http.py +0 -0
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/_imports.py +0 -0
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/amr.py +0 -0
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/exceptions.py +0 -0
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/oauth/__init__.py +0 -0
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/py.typed +0 -0
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/__init__.py +0 -0
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/oauth2.py +0 -0
- {fastapi_multiauth-1.1.0 → fastapi_multiauth-1.2.0}/src/fastapi_multiauth/sources/query.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -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
|
|
212
|
-
cls
|
|
213
|
-
(
|
|
214
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
547
|
-
|
|
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,
|
|
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
|
|
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.
|
|
161
|
-
self.
|
|
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.
|
|
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
|
-
"""
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
287
|
-
|
|
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
|
|
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 =
|
|
116
|
-
self.
|
|
117
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
219
|
-
|
|
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
|
|
236
|
-
signs every application's tokens with
|
|
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
|
-
|
|
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
|
-
|
|
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:
|